From db43aadeb67bdd1961386749619995f847aa990d Mon Sep 17 00:00:00 2001 From: simeng-li Date: Tue, 6 Aug 2024 15:07:25 +0800 Subject: [PATCH 1/8] refactor(experience): migrate the password register and sign-in migrate the password register and sign-in flow --- packages/experience/src/apis/experience.ts | 72 +++++++++++++++++++ .../use-register-with-username.ts | 14 ++-- .../components/PasswordSignInForm/index.tsx | 2 +- .../experience/src/hooks/use-error-handler.ts | 2 +- .../src/hooks/use-password-policy-checker.ts | 32 +++++++++ .../hooks/use-password-rejection-handler.ts | 31 ++++++++ .../src/hooks/use-password-sign-in.ts | 12 ++-- .../src/pages/RegisterPassword/index.tsx | 46 +++++++----- .../PasswordForm/index.test.tsx | 8 ++- .../SignInPassword/PasswordForm/index.tsx | 4 +- 10 files changed, 190 insertions(+), 33 deletions(-) create mode 100644 packages/experience/src/apis/experience.ts create mode 100644 packages/experience/src/hooks/use-password-policy-checker.ts create mode 100644 packages/experience/src/hooks/use-password-rejection-handler.ts diff --git a/packages/experience/src/apis/experience.ts b/packages/experience/src/apis/experience.ts new file mode 100644 index 00000000000..ac19fb356e2 --- /dev/null +++ b/packages/experience/src/apis/experience.ts @@ -0,0 +1,72 @@ +import { + type IdentificationApiPayload, + InteractionEvent, + type PasswordVerificationPayload, + SignInIdentifier, + type UpdateProfileApiPayload, +} from '@logto/schemas'; + +import api from './api'; + +const prefix = '/api/experience'; + +const experienceRoutes = Object.freeze({ + prefix, + identification: `${prefix}/identification`, + verification: `${prefix}/verification`, + profile: `${prefix}/profile`, + mfa: `${prefix}/profile/mfa`, +}); + +type VerificationResponse = { + verificationId: string; +}; + +type SubmitInteractionResponse = { + redirectTo: string; +}; + +const initInteraction = async (interactionEvent: InteractionEvent) => + api.put(`${experienceRoutes.prefix}`, { + json: { + interactionEvent, + }, + }); + +const identifyUser = async (payload: IdentificationApiPayload = {}) => + api.post(experienceRoutes.identification, { json: payload }); + +const submitInteraction = async () => + api.post(`${experienceRoutes.prefix}/submit`).json(); + +const updateProfile = async (payload: UpdateProfileApiPayload) => { + await api.post(experienceRoutes.profile, { json: payload }); +}; + +export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => { + await initInteraction(InteractionEvent.SignIn); + + const { verificationId } = await api + .post(`${experienceRoutes.verification}/password`, { + json: payload, + }) + .json(); + + await identifyUser({ verificationId }); + + return submitInteraction(); +}; + +export const registerWithUsername = async (username: string) => { + await initInteraction(InteractionEvent.Register); + + return updateProfile({ type: SignInIdentifier.Username, value: username }); +}; + +export const continueRegisterWithPassword = async (password: string) => { + await updateProfile({ type: 'password', value: password }); + + await identifyUser(); + + return submitInteraction(); +}; diff --git a/packages/experience/src/components/IdentifierRegisterForm/use-register-with-username.ts b/packages/experience/src/components/IdentifierRegisterForm/use-register-with-username.ts index cd9b41e75dd..6bc4b411232 100644 --- a/packages/experience/src/components/IdentifierRegisterForm/use-register-with-username.ts +++ b/packages/experience/src/components/IdentifierRegisterForm/use-register-with-username.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { registerWithUsernamePassword } from '@/apis/interaction'; +import { registerWithUsername } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; @@ -19,15 +19,12 @@ const useRegisterWithUsername = () => { 'user.username_already_in_use': (error) => { setErrorMessage(error.message); }, - 'user.missing_profile': () => { - navigate('password'); - }, }), - [navigate] + [] ); const handleError = useErrorHandler(); - const asyncRegister = useApi(registerWithUsernamePassword); + const asyncRegister = useApi(registerWithUsername); const onSubmit = useCallback( async (username: string) => { @@ -35,9 +32,12 @@ const useRegisterWithUsername = () => { if (error) { await handleError(error, errorHandlers); + return; } + + navigate('password'); }, - [asyncRegister, errorHandlers, handleError] + [asyncRegister, errorHandlers, handleError, navigate] ); return { errorMessage, clearErrorMessage, onSubmit }; diff --git a/packages/experience/src/components/PasswordSignInForm/index.tsx b/packages/experience/src/components/PasswordSignInForm/index.tsx index ef595faeead..10086fe5183 100644 --- a/packages/experience/src/components/PasswordSignInForm/index.tsx +++ b/packages/experience/src/components/PasswordSignInForm/index.tsx @@ -82,7 +82,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => { } await onSubmit({ - [type]: value, + identifier: { type, value }, password, }); })(event); diff --git a/packages/experience/src/hooks/use-error-handler.ts b/packages/experience/src/hooks/use-error-handler.ts index ed21aed4830..2dea1752e22 100644 --- a/packages/experience/src/hooks/use-error-handler.ts +++ b/packages/experience/src/hooks/use-error-handler.ts @@ -34,7 +34,7 @@ const useErrorHandler = () => { } return; - } catch { + } catch (error) { setToast(t('error.unknown')); console.error(error); diff --git a/packages/experience/src/hooks/use-password-policy-checker.ts b/packages/experience/src/hooks/use-password-policy-checker.ts new file mode 100644 index 00000000000..afc08ff7240 --- /dev/null +++ b/packages/experience/src/hooks/use-password-policy-checker.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; + +import usePasswordErrorMessage from './use-password-error-message'; +import { usePasswordPolicy } from './use-sie'; + +type PasswordPolicyCheckProps = { + setErrorMessage: (message?: string) => void; +}; + +const usePasswordPolicyChecker = ({ setErrorMessage }: PasswordPolicyCheckProps) => { + const { getErrorMessage } = usePasswordErrorMessage(); + const { policyChecker } = usePasswordPolicy(); + + const checkPassword = useCallback( + async (password: string) => { + // Perform fast check before sending request + const fastCheckErrorMessage = getErrorMessage(policyChecker.fastCheck(password)); + + if (fastCheckErrorMessage) { + setErrorMessage(fastCheckErrorMessage); + return false; + } + + return true; + }, + [getErrorMessage, policyChecker, setErrorMessage] + ); + + return checkPassword; +}; + +export default usePasswordPolicyChecker; diff --git a/packages/experience/src/hooks/use-password-rejection-handler.ts b/packages/experience/src/hooks/use-password-rejection-handler.ts new file mode 100644 index 00000000000..99969f039d2 --- /dev/null +++ b/packages/experience/src/hooks/use-password-rejection-handler.ts @@ -0,0 +1,31 @@ +import { type RequestErrorBody } from '@logto/schemas'; +import { useCallback, useMemo } from 'react'; + +import type { ErrorHandlers } from './use-error-handler'; +import usePasswordErrorMessage from './use-password-error-message'; + +type ErrorHandlerProps = { + setErrorMessage: (message?: string) => void; +}; + +const usePasswordRejectionErrorHandler = ({ setErrorMessage }: ErrorHandlerProps) => { + const { getErrorMessageFromBody } = usePasswordErrorMessage(); + + const passwordRejectionHandler = useCallback( + (error: RequestErrorBody) => { + setErrorMessage(getErrorMessageFromBody(error)); + }, + [getErrorMessageFromBody, setErrorMessage] + ); + + const passwordRejectionErrorHandler = useMemo( + () => ({ + 'password.rejected': passwordRejectionHandler, + }), + [passwordRejectionHandler] + ); + + return passwordRejectionErrorHandler; +}; + +export default usePasswordRejectionErrorHandler; diff --git a/packages/experience/src/hooks/use-password-sign-in.ts b/packages/experience/src/hooks/use-password-sign-in.ts index 23cce8bcb07..fd8b3ac8cc5 100644 --- a/packages/experience/src/hooks/use-password-sign-in.ts +++ b/packages/experience/src/hooks/use-password-sign-in.ts @@ -1,7 +1,7 @@ +import { SignInIdentifier, type PasswordVerificationPayload } from '@logto/schemas'; import { useCallback, useMemo, useState } from 'react'; -import type { PasswordSignInPayload } from '@/apis/interaction'; -import { signInWithPasswordIdentifier } from '@/apis/interaction'; +import { signInWithPasswordIdentifier } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; @@ -34,10 +34,12 @@ const usePasswordSignIn = () => { ); const onSubmit = useCallback( - async (payload: PasswordSignInPayload) => { + async (payload: PasswordVerificationPayload) => { + const { identifier } = payload; + // Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step - if (payload.email) { - const result = await checkSingleSignOn(payload.email); + if (identifier.type === SignInIdentifier.Email) { + const result = await checkSingleSignOn(identifier.value); if (result) { return; diff --git a/packages/experience/src/pages/RegisterPassword/index.tsx b/packages/experience/src/pages/RegisterPassword/index.tsx index 4c0c722aa70..227a499b9dc 100644 --- a/packages/experience/src/pages/RegisterPassword/index.tsx +++ b/packages/experience/src/pages/RegisterPassword/index.tsx @@ -3,13 +3,15 @@ import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; -import { setUserPassword } from '@/apis/interaction'; +import { continueRegisterWithPassword } from '@/apis/experience'; import SetPassword from '@/containers/SetPassword'; +import useApi from '@/hooks/use-api'; import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; -import { type ErrorHandlers } from '@/hooks/use-error-handler'; +import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import useMfaErrorHandler from '@/hooks/use-mfa-error-handler'; -import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action'; +import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker'; +import usePasswordRejectionErrorHandler from '@/hooks/use-password-rejection-handler'; import { usePasswordPolicy, useSieMethods } from '@/hooks/use-sie'; import ErrorPage from '../ErrorPage'; @@ -25,7 +27,12 @@ const RegisterPassword = () => { setErrorMessage(undefined); }, []); + const checkPassword = usePasswordPolicyChecker({ setErrorMessage }); + const asyncRegisterPassword = useApi(continueRegisterWithPassword); + const handleError = useErrorHandler(); + const mfaErrorHandler = useMfaErrorHandler({ replace: true }); + const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage }); const errorHandlers: ErrorHandlers = useMemo( () => ({ @@ -35,26 +42,33 @@ const RegisterPassword = () => { navigate(-1); }, ...mfaErrorHandler, + ...passwordRejectionErrorHandler, }), - [navigate, mfaErrorHandler, show] + [mfaErrorHandler, passwordRejectionErrorHandler, show, navigate] ); - const successHandler: SuccessHandler = useCallback( - async (result) => { - if (result && 'redirectTo' in result) { + const onSubmitHandler = useCallback( + async (password: string) => { + const success = await checkPassword(password); + + if (!success) { + return; + } + + const [error, result] = await asyncRegisterPassword(password); + + if (error) { + await handleError(error, errorHandlers); + return; + } + + if (result) { await redirectTo(result.redirectTo); } }, - [redirectTo] + [asyncRegisterPassword, checkPassword, errorHandlers, handleError, redirectTo] ); - const [action] = usePasswordAction({ - api: setUserPassword, - setErrorMessage, - errorHandlers, - successHandler, - }); - const { policy: { length: { min, max }, @@ -78,7 +92,7 @@ const RegisterPassword = () => { errorMessage={errorMessage} maxLength={max} clearErrorMessage={clearErrorMessage} - onSubmit={action} + onSubmit={onSubmitHandler} /> ); diff --git a/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx b/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx index 08d948fc0da..cf255021db9 100644 --- a/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx +++ b/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx @@ -71,7 +71,13 @@ describe('PasswordSignInForm', () => { }); await waitFor(() => { - expect(signInWithPasswordIdentifier).toBeCalledWith({ [identifier]: value, password }); + expect(signInWithPasswordIdentifier).toBeCalledWith({ + identifier: { + type: identifier, + value, + }, + password, + }); }); if (isVerificationCodeEnabled) { diff --git a/packages/experience/src/pages/SignInPassword/PasswordForm/index.tsx b/packages/experience/src/pages/SignInPassword/PasswordForm/index.tsx index 7707ee22bb0..b362e3d1acf 100644 --- a/packages/experience/src/pages/SignInPassword/PasswordForm/index.tsx +++ b/packages/experience/src/pages/SignInPassword/PasswordForm/index.tsx @@ -1,7 +1,7 @@ import { SignInIdentifier } from '@logto/schemas'; import classNames from 'classnames'; import { useCallback, useContext, useEffect } from 'react'; -import { useForm, Controller } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; @@ -77,7 +77,7 @@ const PasswordForm = ({ setIdentifierInputValue({ type, value }); await onSubmit({ - [type]: value, + identifier: { type, value }, password, }); })(event); From fc2753b2bc4d0494cdb875ef3243eb8943923613 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 14 Aug 2024 15:43:01 +0800 Subject: [PATCH 2/8] fix(experience): update some namings update some namings --- packages/experience/src/apis/experience.ts | 13 +++++++------ .../src/hooks/use-password-policy-checker.ts | 4 ++-- .../src/hooks/use-password-rejection-handler.ts | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/experience/src/apis/experience.ts b/packages/experience/src/apis/experience.ts index ac19fb356e2..50fa4ddf19d 100644 --- a/packages/experience/src/apis/experience.ts +++ b/packages/experience/src/apis/experience.ts @@ -10,9 +10,10 @@ import api from './api'; const prefix = '/api/experience'; -const experienceRoutes = Object.freeze({ +const experienceApiRoutes = Object.freeze({ prefix, identification: `${prefix}/identification`, + submit: `${prefix}/submit`, verification: `${prefix}/verification`, profile: `${prefix}/profile`, mfa: `${prefix}/profile/mfa`, @@ -27,27 +28,27 @@ type SubmitInteractionResponse = { }; const initInteraction = async (interactionEvent: InteractionEvent) => - api.put(`${experienceRoutes.prefix}`, { + api.put(`${experienceApiRoutes.prefix}`, { json: { interactionEvent, }, }); const identifyUser = async (payload: IdentificationApiPayload = {}) => - api.post(experienceRoutes.identification, { json: payload }); + api.post(experienceApiRoutes.identification, { json: payload }); const submitInteraction = async () => - api.post(`${experienceRoutes.prefix}/submit`).json(); + api.post(`${experienceApiRoutes.submit}`).json(); const updateProfile = async (payload: UpdateProfileApiPayload) => { - await api.post(experienceRoutes.profile, { json: payload }); + await api.post(experienceApiRoutes.profile, { json: payload }); }; export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => { await initInteraction(InteractionEvent.SignIn); const { verificationId } = await api - .post(`${experienceRoutes.verification}/password`, { + .post(`${experienceApiRoutes.verification}/password`, { json: payload, }) .json(); diff --git a/packages/experience/src/hooks/use-password-policy-checker.ts b/packages/experience/src/hooks/use-password-policy-checker.ts index afc08ff7240..0db020b1b8f 100644 --- a/packages/experience/src/hooks/use-password-policy-checker.ts +++ b/packages/experience/src/hooks/use-password-policy-checker.ts @@ -3,11 +3,11 @@ import { useCallback } from 'react'; import usePasswordErrorMessage from './use-password-error-message'; import { usePasswordPolicy } from './use-sie'; -type PasswordPolicyCheckProps = { +type Options = { setErrorMessage: (message?: string) => void; }; -const usePasswordPolicyChecker = ({ setErrorMessage }: PasswordPolicyCheckProps) => { +const usePasswordPolicyChecker = ({ setErrorMessage }: Options) => { const { getErrorMessage } = usePasswordErrorMessage(); const { policyChecker } = usePasswordPolicy(); diff --git a/packages/experience/src/hooks/use-password-rejection-handler.ts b/packages/experience/src/hooks/use-password-rejection-handler.ts index 99969f039d2..6a4f8f84b11 100644 --- a/packages/experience/src/hooks/use-password-rejection-handler.ts +++ b/packages/experience/src/hooks/use-password-rejection-handler.ts @@ -4,11 +4,11 @@ import { useCallback, useMemo } from 'react'; import type { ErrorHandlers } from './use-error-handler'; import usePasswordErrorMessage from './use-password-error-message'; -type ErrorHandlerProps = { +type Options = { setErrorMessage: (message?: string) => void; }; -const usePasswordRejectionErrorHandler = ({ setErrorMessage }: ErrorHandlerProps) => { +const usePasswordRejectionErrorHandler = ({ setErrorMessage }: Options) => { const { getErrorMessageFromBody } = usePasswordErrorMessage(); const passwordRejectionHandler = useCallback( From 87925acbf59a213ccb36e642a9b1fb87612b030d Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 21 Aug 2024 10:30:28 +0800 Subject: [PATCH 3/8] refactor(experience): refactor the verification code flow (migration-2) (#6408) * refactor(experience): refactor the verificaiton code flow refactor the verification code flow * refactor(experience): migrate the social and sso flow (migration-3) (#6406) * refactor(experience): migrate the social and sso flow migrate the social and sso flow * refactor(experience): migrate profile fulfillment flow (migration-4) (#6414) * refactor(experience): migrate profile fulfillment flow migrate the profile fulfillment flow * refactor(experience): remove unused hook remove unused hook * fix(experience): fix password policy checker fix password policy checker error display * fix(experience): fix the api name fix the api name * refactor(experience): migrate mfa flow (migration-5) (#6417) * refactor(experience): migrate mfa binding flow migrate mfa binding flow * test(experience): update unit tests (migration-6) (#6420) * test(experience): update unit tests update unit tests * chore(experience): remove legacy APIs remove legacy APIs * refactor(experience): revert api prefix revert api prefix * fix(experience): update the sso connectors endpoint update the sso connectors endpoint --- .../UserInteractionContext.tsx | 7 +- .../UserInteractionContextProvider/index.tsx | 26 +- packages/experience/src/apis/const.ts | 1 + packages/experience/src/apis/experience.ts | 73 ----- .../experience/src/apis/experience/const.ts | 14 + .../experience/src/apis/experience/index.ts | 149 ++++++++++ .../src/apis/experience/interaction.ts | 41 +++ .../experience/src/apis/experience/mfa.ts | 129 +++++++++ .../experience/src/apis/experience/social.ts | 96 +++++++ packages/experience/src/apis/interaction.ts | 262 ------------------ .../experience/src/apis/single-sign-on.ts | 53 ---- packages/experience/src/apis/utils.ts | 55 +++- .../IdentifierRegisterForm/index.test.tsx | 77 +++-- .../IdentifierSignInForm/index.test.tsx | 27 +- .../PasswordSignInForm/index.test.tsx | 31 ++- .../SocialLinkAccount/index.test.tsx | 44 ++- .../containers/SocialLinkAccount/index.tsx | 10 +- .../use-social-link-related-user.ts | 2 +- .../containers/SocialSignInList/use-social.ts | 17 +- .../containers/TotpCodeVerification/index.tsx | 23 +- .../use-totp-code-verification.ts | 13 +- .../VerificationCode/index.test.tsx | 195 ++++++------- .../src/containers/VerificationCode/index.tsx | 27 +- .../use-continue-flow-code-verification.ts | 91 +++--- ...-forgot-password-flow-code-verification.ts | 45 ++- .../use-link-social-confirm-modal.ts | 22 +- .../use-register-flow-code-verification.ts | 72 +++-- .../use-resend-verification-code.ts | 24 +- .../use-sign-in-flow-code-verification.ts | 74 ++--- .../src/hooks/use-check-single-sign-on.ts | 10 +- .../src/hooks/use-mfa-error-handler.ts | 30 +- .../src/hooks/use-password-action.ts | 78 ------ .../hooks/use-pre-sign-in-error-handler.ts | 4 +- .../use-required-profile-error-handler.ts | 25 +- .../src/hooks/use-send-mfa-payload.ts | 10 +- .../src/hooks/use-send-verification-code.ts | 36 ++- .../src/hooks/use-session-storages.ts | 8 +- .../src/hooks/use-single-sign-on-watch.ts | 8 +- .../src/hooks/use-single-sign-on.ts | 28 +- packages/experience/src/hooks/use-skip-mfa.ts | 2 +- .../src/hooks/use-social-link-account.ts | 17 +- .../src/hooks/use-social-register.ts | 21 +- .../hooks/use-start-backup-code-binding.ts | 46 +++ .../src/hooks/use-start-totp-binding.ts | 18 +- .../hooks/use-start-webauthn-processing.ts | 31 ++- .../src/hooks/use-webauthn-operation.ts | 33 ++- .../Continue/SetEmailOrPhone/index.test.tsx | 24 +- .../pages/Continue/SetEmailOrPhone/index.tsx | 10 +- .../pages/Continue/SetPassword/index.test.tsx | 23 +- .../src/pages/Continue/SetPassword/index.tsx | 57 ++-- .../pages/Continue/SetUsername/index.test.tsx | 16 +- .../src/pages/Continue/SetUsername/index.tsx | 9 +- .../Continue/SetUsername/use-set-username.ts | 19 +- .../experience/src/pages/Continue/index.tsx | 19 +- .../ForgotPasswordForm/index.test.tsx | 21 +- .../MfaBinding/BackupCodeBinding/index.tsx | 8 +- .../TotpBinding/VerificationSection.tsx | 8 +- .../pages/MfaBinding/TotpBinding/index.tsx | 10 +- .../MfaBinding/WebAuthnBinding/index.tsx | 11 +- .../WebAuthnVerification/index.tsx | 11 +- .../src/pages/RegisterPassword/index.test.tsx | 8 +- .../src/pages/ResetPassword/index.test.tsx | 8 +- .../src/pages/ResetPassword/index.tsx | 63 +++-- .../PasswordForm/index.test.tsx | 15 +- .../src/pages/SignInPassword/index.test.tsx | 2 - .../pages/SocialLinkAccount/index.test.tsx | 46 ++- .../src/pages/SocialLinkAccount/index.tsx | 18 +- .../SocialSignInWebCallback/index.test.tsx | 80 ++++-- .../use-single-sign-on-listener.ts | 40 ++- .../use-social-sign-in-listener.ts | 118 ++++++-- .../src/pages/VerificationCode/index.test.tsx | 3 +- .../src/pages/VerificationCode/index.tsx | 32 ++- packages/experience/src/types/guard.test.ts | 31 +++ packages/experience/src/types/guard.ts | 36 ++- packages/experience/src/types/index.ts | 3 + .../src/utils/sign-in-experience.ts | 7 +- 76 files changed, 1732 insertions(+), 1129 deletions(-) create mode 100644 packages/experience/src/apis/const.ts delete mode 100644 packages/experience/src/apis/experience.ts create mode 100644 packages/experience/src/apis/experience/const.ts create mode 100644 packages/experience/src/apis/experience/index.ts create mode 100644 packages/experience/src/apis/experience/interaction.ts create mode 100644 packages/experience/src/apis/experience/mfa.ts create mode 100644 packages/experience/src/apis/experience/social.ts delete mode 100644 packages/experience/src/apis/interaction.ts delete mode 100644 packages/experience/src/apis/single-sign-on.ts delete mode 100644 packages/experience/src/hooks/use-password-action.ts create mode 100644 packages/experience/src/hooks/use-start-backup-code-binding.ts create mode 100644 packages/experience/src/types/guard.test.ts diff --git a/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx b/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx index 845a9260e8a..e6751deb453 100644 --- a/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx +++ b/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx @@ -1,8 +1,9 @@ -import { type SsoConnectorMetadata } from '@logto/schemas'; +import { type SsoConnectorMetadata, type VerificationType } from '@logto/schemas'; import { noop } from '@silverhand/essentials'; import { createContext } from 'react'; import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField'; +import { type VerificationIdsMap } from '@/types/guard'; export type UserInteractionContextType = { // All the enabled sso connectors @@ -31,6 +32,8 @@ export type UserInteractionContextType = { setForgotPasswordIdentifierInputValue: React.Dispatch< React.SetStateAction >; + verificationIdsMap: VerificationIdsMap; + setVerificationId: (type: VerificationType, id: string) => void; /** * This method only clear the identifier input values from the session storage. * @@ -54,5 +57,7 @@ export default createContext({ setIdentifierInputValue: noop, forgotPasswordIdentifierInputValue: undefined, setForgotPasswordIdentifierInputValue: noop, + verificationIdsMap: {}, + setVerificationId: noop, clearInteractionContextSessionStorage: noop, }); diff --git a/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx index 1a4133e28db..80af8b7127c 100644 --- a/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx +++ b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx @@ -1,5 +1,5 @@ -import { type SsoConnectorMetadata } from '@logto/schemas'; -import { type ReactNode, useEffect, useMemo, useState, useCallback } from 'react'; +import { type SsoConnectorMetadata, type VerificationType } from '@logto/schemas'; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; @@ -35,6 +35,10 @@ const UserInteractionContextProvider = ({ children }: Props) => { IdentifierInputValue | undefined >(get(StorageKeys.ForgotPasswordIdentifierInputValue)); + const [verificationIdsMap, setVerificationIdsMap] = useState( + get(StorageKeys.verificationIds) ?? {} + ); + useEffect(() => { if (!ssoEmail) { remove(StorageKeys.SsoEmail); @@ -71,6 +75,15 @@ const UserInteractionContextProvider = ({ children }: Props) => { set(StorageKeys.ForgotPasswordIdentifierInputValue, forgotPasswordIdentifierInputValue); }, [forgotPasswordIdentifierInputValue, remove, set]); + useEffect(() => { + if (Object.keys(verificationIdsMap).length === 0) { + remove(StorageKeys.verificationIds); + return; + } + + set(StorageKeys.verificationIds, verificationIdsMap); + }, [verificationIdsMap, remove, set]); + const ssoConnectorsMap = useMemo( () => new Map(ssoConnectors.map((connector) => [connector.id, connector])), [ssoConnectors] @@ -79,8 +92,13 @@ const UserInteractionContextProvider = ({ children }: Props) => { const clearInteractionContextSessionStorage = useCallback(() => { remove(StorageKeys.IdentifierInputValue); remove(StorageKeys.ForgotPasswordIdentifierInputValue); + remove(StorageKeys.verificationIds); }, [remove]); + const setVerificationId = useCallback((type: VerificationType, id: string) => { + setVerificationIdsMap((previous) => ({ ...previous, [type]: id })); + }, []); + const userInteractionContext = useMemo( () => ({ ssoEmail, @@ -92,6 +110,8 @@ const UserInteractionContextProvider = ({ children }: Props) => { setIdentifierInputValue, forgotPasswordIdentifierInputValue, setForgotPasswordIdentifierInputValue, + verificationIdsMap, + setVerificationId, clearInteractionContextSessionStorage, }), [ @@ -100,6 +120,8 @@ const UserInteractionContextProvider = ({ children }: Props) => { domainFilteredConnectors, identifierInputValue, forgotPasswordIdentifierInputValue, + verificationIdsMap, + setVerificationId, clearInteractionContextSessionStorage, ] ); diff --git a/packages/experience/src/apis/const.ts b/packages/experience/src/apis/const.ts new file mode 100644 index 00000000000..992d54ce9b0 --- /dev/null +++ b/packages/experience/src/apis/const.ts @@ -0,0 +1 @@ +export const kyPrefixUrl = '/'; diff --git a/packages/experience/src/apis/experience.ts b/packages/experience/src/apis/experience.ts deleted file mode 100644 index 50fa4ddf19d..00000000000 --- a/packages/experience/src/apis/experience.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - type IdentificationApiPayload, - InteractionEvent, - type PasswordVerificationPayload, - SignInIdentifier, - type UpdateProfileApiPayload, -} from '@logto/schemas'; - -import api from './api'; - -const prefix = '/api/experience'; - -const experienceApiRoutes = Object.freeze({ - prefix, - identification: `${prefix}/identification`, - submit: `${prefix}/submit`, - verification: `${prefix}/verification`, - profile: `${prefix}/profile`, - mfa: `${prefix}/profile/mfa`, -}); - -type VerificationResponse = { - verificationId: string; -}; - -type SubmitInteractionResponse = { - redirectTo: string; -}; - -const initInteraction = async (interactionEvent: InteractionEvent) => - api.put(`${experienceApiRoutes.prefix}`, { - json: { - interactionEvent, - }, - }); - -const identifyUser = async (payload: IdentificationApiPayload = {}) => - api.post(experienceApiRoutes.identification, { json: payload }); - -const submitInteraction = async () => - api.post(`${experienceApiRoutes.submit}`).json(); - -const updateProfile = async (payload: UpdateProfileApiPayload) => { - await api.post(experienceApiRoutes.profile, { json: payload }); -}; - -export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => { - await initInteraction(InteractionEvent.SignIn); - - const { verificationId } = await api - .post(`${experienceApiRoutes.verification}/password`, { - json: payload, - }) - .json(); - - await identifyUser({ verificationId }); - - return submitInteraction(); -}; - -export const registerWithUsername = async (username: string) => { - await initInteraction(InteractionEvent.Register); - - return updateProfile({ type: SignInIdentifier.Username, value: username }); -}; - -export const continueRegisterWithPassword = async (password: string) => { - await updateProfile({ type: 'password', value: password }); - - await identifyUser(); - - return submitInteraction(); -}; diff --git a/packages/experience/src/apis/experience/const.ts b/packages/experience/src/apis/experience/const.ts new file mode 100644 index 00000000000..6e77fb82028 --- /dev/null +++ b/packages/experience/src/apis/experience/const.ts @@ -0,0 +1,14 @@ +export const prefix = '/api/experience'; + +export const experienceApiRoutes = Object.freeze({ + prefix, + identification: `${prefix}/identification`, + submit: `${prefix}/submit`, + verification: `${prefix}/verification`, + profile: `${prefix}/profile`, + mfa: `${prefix}/profile/mfa`, +}); + +export type VerificationResponse = { + verificationId: string; +}; diff --git a/packages/experience/src/apis/experience/index.ts b/packages/experience/src/apis/experience/index.ts new file mode 100644 index 00000000000..4b4267548e9 --- /dev/null +++ b/packages/experience/src/apis/experience/index.ts @@ -0,0 +1,149 @@ +import { + InteractionEvent, + type PasswordVerificationPayload, + SignInIdentifier, + type VerificationCodeIdentifier, +} from '@logto/schemas'; + +import { type ContinueFlowInteractionEvent } from '@/types'; + +import api from '../api'; + +import { experienceApiRoutes, type VerificationResponse } from './const'; +import { + initInteraction, + identifyUser, + submitInteraction, + updateInteractionEvent, + _updateProfile, + identifyAndSubmitInteraction, +} from './interaction'; + +export { + initInteraction, + submitInteraction, + identifyUser, + identifyAndSubmitInteraction, +} from './interaction'; + +export * from './mfa'; +export * from './social'; + +export const registerWithVerifiedIdentifier = async (verificationId: string) => { + await updateInteractionEvent(InteractionEvent.Register); + return identifyAndSubmitInteraction({ verificationId }); +}; + +export const signInWithVerifiedIdentifier = async (verificationId: string) => { + await updateInteractionEvent(InteractionEvent.SignIn); + return identifyAndSubmitInteraction({ verificationId }); +}; + +// Password APIs +export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => { + await initInteraction(InteractionEvent.SignIn); + + const { verificationId } = await api + .post(`${experienceApiRoutes.verification}/password`, { + json: payload, + }) + .json(); + + return identifyAndSubmitInteraction({ verificationId }); +}; + +export const registerWithUsername = async (username: string) => { + await initInteraction(InteractionEvent.Register); + + return _updateProfile({ type: SignInIdentifier.Username, value: username }); +}; + +export const continueRegisterWithPassword = async (password: string) => { + await _updateProfile({ type: 'password', value: password }); + + return identifyAndSubmitInteraction(); +}; + +// Verification code APIs +type VerificationCodePayload = { + identifier: VerificationCodeIdentifier; + code: string; + verificationId: string; +}; + +export const sendVerificationCode = async ( + interactionEvent: InteractionEvent, + identifier: VerificationCodeIdentifier +) => + api + .post(`${experienceApiRoutes.verification}/verification-code`, { + json: { + interactionEvent, + identifier, + }, + }) + .json(); + +const verifyVerificationCode = async (json: VerificationCodePayload) => + api + .post(`${experienceApiRoutes.verification}/verification-code/verify`, { + json, + }) + .json(); + +export const identifyWithVerificationCode = async (json: VerificationCodePayload) => { + const { verificationId } = await verifyVerificationCode(json); + return identifyAndSubmitInteraction({ verificationId }); +}; + +// Profile APIs + +export const updateProfileWithVerificationCode = async ( + json: VerificationCodePayload, + interactionEvent?: ContinueFlowInteractionEvent +) => { + const { verificationId } = await verifyVerificationCode(json); + + const { + identifier: { type }, + } = json; + + await _updateProfile({ + type, + verificationId, + }); + + if (interactionEvent === InteractionEvent.Register) { + await identifyUser(); + } + + return submitInteraction(); +}; + +type UpdateProfilePayload = { + type: SignInIdentifier.Username | 'password'; + value: string; +}; + +export const updateProfile = async ( + payload: UpdateProfilePayload, + interactionEvent: ContinueFlowInteractionEvent +) => { + await _updateProfile(payload); + + if (interactionEvent === InteractionEvent.Register) { + await identifyUser(); + } + + return submitInteraction(); +}; + +export const resetPassword = async (password: string) => { + await api.put(`${experienceApiRoutes.profile}/password`, { + json: { + password, + }, + }); + + return submitInteraction(); +}; diff --git a/packages/experience/src/apis/experience/interaction.ts b/packages/experience/src/apis/experience/interaction.ts new file mode 100644 index 00000000000..9e5a63685c2 --- /dev/null +++ b/packages/experience/src/apis/experience/interaction.ts @@ -0,0 +1,41 @@ +import { + type InteractionEvent, + type IdentificationApiPayload, + type UpdateProfileApiPayload, +} from '@logto/schemas'; + +import api from '../api'; + +import { experienceApiRoutes } from './const'; + +type SubmitInteractionResponse = { + redirectTo: string; +}; + +export const initInteraction = async (interactionEvent: InteractionEvent) => + api.put(`${experienceApiRoutes.prefix}`, { + json: { + interactionEvent, + }, + }); + +export const identifyUser = async (payload: IdentificationApiPayload = {}) => + api.post(experienceApiRoutes.identification, { json: payload }); + +export const submitInteraction = async () => + api.post(`${experienceApiRoutes.submit}`).json(); + +export const _updateProfile = async (payload: UpdateProfileApiPayload) => + api.post(experienceApiRoutes.profile, { json: payload }); + +export const updateInteractionEvent = async (interactionEvent: InteractionEvent) => + api.put(`${experienceApiRoutes.prefix}/interaction-event`, { + json: { + interactionEvent, + }, + }); + +export const identifyAndSubmitInteraction = async (payload?: IdentificationApiPayload) => { + await identifyUser(payload); + return submitInteraction(); +}; diff --git a/packages/experience/src/apis/experience/mfa.ts b/packages/experience/src/apis/experience/mfa.ts new file mode 100644 index 00000000000..f2ab7b5e5ee --- /dev/null +++ b/packages/experience/src/apis/experience/mfa.ts @@ -0,0 +1,129 @@ +import { + MfaFactor, + type WebAuthnRegistrationOptions, + type WebAuthnAuthenticationOptions, + type BindMfaPayload, + type VerifyMfaPayload, +} from '@logto/schemas'; + +import api from '../api'; + +import { experienceApiRoutes } from './const'; +import { submitInteraction } from './interaction'; + +/** + * Mfa APIs + */ +const addMfa = async (type: MfaFactor, verificationId: string) => + api.post(`${experienceApiRoutes.mfa}`, { + json: { + type, + verificationId, + }, + }); + +type TotpSecretResponse = { + verificationId: string; + secret: string; + secretQrCode: string; +}; +export const createTotpSecret = async () => + api.post(`${experienceApiRoutes.verification}/totp/secret`).json(); + +export const createWebAuthnRegistration = async () => { + const { verificationId, registrationOptions } = await api + .post(`${experienceApiRoutes.verification}/web-authn/registration`) + .json<{ verificationId: string; registrationOptions: WebAuthnRegistrationOptions }>(); + + return { + verificationId, + options: registrationOptions, + }; +}; + +export const createWebAuthnAuthentication = async () => { + const { verificationId, authenticationOptions } = await api + .post(`${experienceApiRoutes.verification}/web-authn/authentication`) + .json<{ verificationId: string; authenticationOptions: WebAuthnAuthenticationOptions }>(); + + return { + verificationId, + options: authenticationOptions, + }; +}; + +export const createBackupCode = async () => + api.post(`${experienceApiRoutes.verification}/backup-code/generate`).json<{ + verificationId: string; + codes: string[]; + }>(); + +export const skipMfa = async () => { + await api.post(`${experienceApiRoutes.mfa}/mfa-skipped`); + return submitInteraction(); +}; + +export const bindMfa = async (payload: BindMfaPayload, verificationId: string) => { + switch (payload.type) { + case MfaFactor.TOTP: { + const { code } = payload; + await api.post(`${experienceApiRoutes.verification}/totp/verify`, { + json: { + code, + verificationId, + }, + }); + break; + } + case MfaFactor.WebAuthn: { + await api.post(`${experienceApiRoutes.verification}/web-authn/registration/verify`, { + json: { + verificationId, + payload, + }, + }); + break; + } + case MfaFactor.BackupCode: { + // No need to verify backup codes + break; + } + } + + await addMfa(payload.type, verificationId); + return submitInteraction(); +}; + +export const verifyMfa = async (payload: VerifyMfaPayload, verificationId?: string) => { + switch (payload.type) { + case MfaFactor.TOTP: { + const { code } = payload; + await api.post(`${experienceApiRoutes.verification}/totp/verify`, { + json: { + code, + }, + }); + break; + } + case MfaFactor.WebAuthn: { + await api.post(`${experienceApiRoutes.verification}/web-authn/authentication/verify`, { + json: { + verificationId, + payload, + }, + }); + break; + } + case MfaFactor.BackupCode: { + const { code } = payload; + await api.post(`${experienceApiRoutes.verification}/backup-code/verify`, { + json: { + code, + }, + }); + break; + } + } + + return submitInteraction(); +}; diff --git a/packages/experience/src/apis/experience/social.ts b/packages/experience/src/apis/experience/social.ts new file mode 100644 index 00000000000..95780446a3e --- /dev/null +++ b/packages/experience/src/apis/experience/social.ts @@ -0,0 +1,96 @@ +// Social and SSO APIs + +import { InteractionEvent, type SocialVerificationCallbackPayload } from '@logto/schemas'; + +import api from '../api'; + +import { experienceApiRoutes, type VerificationResponse } from './const'; +import { + identifyAndSubmitInteraction, + initInteraction, + updateInteractionEvent, + identifyUser, + submitInteraction, + _updateProfile, +} from './interaction'; + +export const getSocialAuthorizationUrl = async ( + connectorId: string, + state: string, + redirectUri: string +) => { + await initInteraction(InteractionEvent.SignIn); + + return api + .post(`${experienceApiRoutes.verification}/social/${connectorId}/authorization-uri`, { + json: { + state, + redirectUri, + }, + }) + .json< + VerificationResponse & { + authorizationUri: string; + } + >(); +}; + +export const verifySocialVerification = async ( + connectorId: string, + payload: SocialVerificationCallbackPayload +) => + api + .post(`${experienceApiRoutes.verification}/social/${connectorId}/verify`, { + json: payload, + }) + .json(); + +export const bindSocialRelatedUser = async (verificationId: string) => { + await updateInteractionEvent(InteractionEvent.SignIn); + await identifyUser({ verificationId, linkSocialIdentity: true }); + return submitInteraction(); +}; + +export const getSsoConnectors = async (email: string) => + api + .get(`${experienceApiRoutes.prefix}/sso-connectors`, { + searchParams: { + email, + }, + }) + .json<{ connectorIds: string[] }>(); + +export const getSsoAuthorizationUrl = async (connectorId: string, payload: unknown) => { + await initInteraction(InteractionEvent.SignIn); + + return api + .post(`${experienceApiRoutes.verification}/sso/${connectorId}/authorization-uri`, { + json: payload, + }) + .json< + VerificationResponse & { + authorizationUri: string; + } + >(); +}; + +export const signInWithSso = async ( + connectorId: string, + payload: SocialVerificationCallbackPayload & { verificationId: string } +) => { + await api.post(`${experienceApiRoutes.verification}/sso/${connectorId}/verify`, { + json: payload, + }); + + return identifyAndSubmitInteraction({ verificationId: payload.verificationId }); +}; + +export const signInAndLinkWithSocial = async ( + verificationId: string, + socialVerificationid: string +) => { + await updateInteractionEvent(InteractionEvent.SignIn); + await identifyUser({ verificationId }); + await _updateProfile({ type: 'social', verificationId: socialVerificationid }); + return submitInteraction(); +}; diff --git a/packages/experience/src/apis/interaction.ts b/packages/experience/src/apis/interaction.ts deleted file mode 100644 index 096ba32a35f..00000000000 --- a/packages/experience/src/apis/interaction.ts +++ /dev/null @@ -1,262 +0,0 @@ -/* istanbul ignore file */ - -import { - InteractionEvent, - type BindMfaPayload, - type EmailVerificationCodePayload, - type PhoneVerificationCodePayload, - type SignInIdentifier, - type SocialConnectorPayload, - type SocialEmailPayload, - type SocialPhonePayload, - type VerifyMfaPayload, - type WebAuthnAuthenticationOptions, - type WebAuthnRegistrationOptions, -} from '@logto/schemas'; -import { conditional } from '@silverhand/essentials'; - -import api from './api'; - -export const interactionPrefix = '/api/interaction'; - -const verificationPath = `verification`; - -type Response = { - redirectTo: string; -}; - -export type PasswordSignInPayload = { [K in SignInIdentifier]?: string } & { password: string }; - -export const signInWithPasswordIdentifier = async (payload: PasswordSignInPayload) => { - await api.put(`${interactionPrefix}`, { - json: { - event: InteractionEvent.SignIn, - identifier: payload, - }, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const registerWithUsernamePassword = async (username: string, password?: string) => { - await api.put(`${interactionPrefix}`, { - json: { - event: InteractionEvent.Register, - profile: { - username, - ...conditional(password && { password }), - }, - }, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const setUserPassword = async (password: string) => { - await api.patch(`${interactionPrefix}/profile`, { - json: { - password, - }, - }); - - const result = await api.post(`${interactionPrefix}/submit`).json(); - - // Reset password does not have any response body - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return result || { success: true }; -}; - -export type SendVerificationCodePayload = { - email?: string; - phone?: string; -}; - -export const putInteraction = async (event: InteractionEvent) => - api.put(`${interactionPrefix}`, { json: { event } }); - -export const sendVerificationCode = async (payload: SendVerificationCodePayload) => { - await api.post(`${interactionPrefix}/${verificationPath}/verification-code`, { json: payload }); - - return { success: true }; -}; - -export const signInWithVerificationCodeIdentifier = async ( - payload: EmailVerificationCodePayload | PhoneVerificationCodePayload -) => { - await api.patch(`${interactionPrefix}/identifiers`, { - json: payload, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const addProfileWithVerificationCodeIdentifier = async ( - payload: EmailVerificationCodePayload | PhoneVerificationCodePayload -) => { - await api.patch(`${interactionPrefix}/identifiers`, { - json: payload, - }); - - const { verificationCode, ...identifier } = payload; - - await api.patch(`${interactionPrefix}/profile`, { - json: identifier, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const verifyForgotPasswordVerificationCodeIdentifier = async ( - payload: EmailVerificationCodePayload | PhoneVerificationCodePayload -) => { - await api.patch(`${interactionPrefix}/identifiers`, { - json: payload, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const signInWithVerifiedIdentifier = async () => { - await api.delete(`${interactionPrefix}/profile`); - - await api.put(`${interactionPrefix}/event`, { - json: { - event: InteractionEvent.SignIn, - }, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const registerWithVerifiedIdentifier = async (payload: SendVerificationCodePayload) => { - await api.put(`${interactionPrefix}/event`, { - json: { - event: InteractionEvent.Register, - }, - }); - - await api.put(`${interactionPrefix}/profile`, { - json: payload, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const addProfile = async (payload: { username: string } | { password: string }) => { - await api.patch(`${interactionPrefix}/profile`, { json: payload }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const getSocialAuthorizationUrl = async ( - connectorId: string, - state: string, - redirectUri: string -) => { - await putInteraction(InteractionEvent.SignIn); - - return api - .post(`${interactionPrefix}/${verificationPath}/social-authorization-uri`, { - json: { - connectorId, - state, - redirectUri, - }, - }) - .json(); -}; - -export const signInWithSocial = async (payload: SocialConnectorPayload) => { - await api.patch(`${interactionPrefix}/identifiers`, { - json: payload, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const registerWithVerifiedSocial = async (connectorId: string) => { - await api.put(`${interactionPrefix}/event`, { - json: { - event: InteractionEvent.Register, - }, - }); - - await api.patch(`${interactionPrefix}/profile`, { - json: { - connectorId, - }, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const bindSocialRelatedUser = async (payload: SocialEmailPayload | SocialPhonePayload) => { - await api.put(`${interactionPrefix}/event`, { - json: { - event: InteractionEvent.SignIn, - }, - }); - - await api.patch(`${interactionPrefix}/identifiers`, { - json: payload, - }); - - await api.patch(`${interactionPrefix}/profile`, { - json: { - connectorId: payload.connectorId, - }, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const linkWithSocial = async (connectorId: string) => { - // Sign-in with pre-verified email/phone identifier instead and replace the email/phone profile with connectorId. - - await api.put(`${interactionPrefix}/event`, { - json: { - event: InteractionEvent.SignIn, - }, - }); - - await api.put(`${interactionPrefix}/profile`, { - json: { - connectorId, - }, - }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const createTotpSecret = async () => - api - .post(`${interactionPrefix}/${verificationPath}/totp`) - .json<{ secret: string; secretQrCode: string }>(); - -export const createWebAuthnRegistrationOptions = async () => - api - .post(`${interactionPrefix}/${verificationPath}/webauthn-registration`) - .json(); - -export const generateWebAuthnAuthnOptions = async () => - api - .post(`${interactionPrefix}/${verificationPath}/webauthn-authentication`) - .json(); - -export const bindMfa = async (payload: BindMfaPayload) => { - await api.post(`${interactionPrefix}/bind-mfa`, { json: payload }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const verifyMfa = async (payload: VerifyMfaPayload) => { - await api.put(`${interactionPrefix}/mfa`, { json: payload }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; - -export const skipMfa = async () => { - await api.put(`${interactionPrefix}/mfa-skipped`, { json: { mfaSkipped: true } }); - - return api.post(`${interactionPrefix}/submit`).json(); -}; diff --git a/packages/experience/src/apis/single-sign-on.ts b/packages/experience/src/apis/single-sign-on.ts deleted file mode 100644 index 12d5c80e2fc..00000000000 --- a/packages/experience/src/apis/single-sign-on.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { InteractionEvent } from '@logto/schemas'; - -import api from './api'; -import { interactionPrefix } from './interaction'; - -const ssoPrefix = `${interactionPrefix}/single-sign-on`; - -type Response = { - redirectTo: string; -}; - -export const getSingleSignOnConnectors = async (email: string) => - api - .get(`${ssoPrefix}/connectors`, { - searchParams: { - email, - }, - }) - .json(); - -export const getSingleSignOnUrl = async ( - connectorId: string, - state: string, - redirectUri: string -) => { - const { redirectTo } = await api - .post(`${ssoPrefix}/${connectorId}/authorization-url`, { - json: { - state, - redirectUri, - }, - }) - .json(); - - return redirectTo; -}; - -export const singleSignOnAuthorization = async (connectorId: string, payload: unknown) => - api - .post(`${ssoPrefix}/${connectorId}/authentication`, { - json: payload, - }) - .json(); - -export const singleSignOnRegistration = async (connectorId: string) => { - await api.put(`${interactionPrefix}/event`, { - json: { - event: InteractionEvent.Register, - }, - }); - - return api.post(`${ssoPrefix}/${connectorId}/registration`).json(); -}; diff --git a/packages/experience/src/apis/utils.ts b/packages/experience/src/apis/utils.ts index 82aa9e2e935..0f9935a8458 100644 --- a/packages/experience/src/apis/utils.ts +++ b/packages/experience/src/apis/utils.ts @@ -1,26 +1,51 @@ -import { InteractionEvent } from '@logto/schemas'; +import { InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas'; -import { UserFlow } from '@/types'; +import { type ContinueFlowInteractionEvent, UserFlow } from '@/types'; -import type { SendVerificationCodePayload } from './interaction'; -import { putInteraction, sendVerificationCode } from './interaction'; +import { initInteraction, sendVerificationCode } from './experience'; /** Move to API */ export const sendVerificationCodeApi = async ( type: UserFlow, - payload: SendVerificationCodePayload + identifier: VerificationCodeIdentifier, + interactionEvent?: ContinueFlowInteractionEvent ) => { - if (type === UserFlow.ForgotPassword) { - await putInteraction(InteractionEvent.ForgotPassword); - } - - if (type === UserFlow.SignIn) { - await putInteraction(InteractionEvent.SignIn); + switch (type) { + case UserFlow.SignIn: { + await initInteraction(InteractionEvent.SignIn); + return sendVerificationCode(InteractionEvent.SignIn, identifier); + } + case UserFlow.Register: { + await initInteraction(InteractionEvent.Register); + return sendVerificationCode(InteractionEvent.Register, identifier); + } + case UserFlow.ForgotPassword: { + await initInteraction(InteractionEvent.ForgotPassword); + return sendVerificationCode(InteractionEvent.ForgotPassword, identifier); + } + case UserFlow.Continue: { + return sendVerificationCode(interactionEvent ?? InteractionEvent.SignIn, identifier); + } } +}; - if (type === UserFlow.Register) { - await putInteraction(InteractionEvent.Register); +export const resendVerificationCodeApi = async ( + type: UserFlow, + identifier: VerificationCodeIdentifier +) => { + switch (type) { + case UserFlow.SignIn: { + return sendVerificationCode(InteractionEvent.SignIn, identifier); + } + case UserFlow.Register: { + return sendVerificationCode(InteractionEvent.Register, identifier); + } + case UserFlow.ForgotPassword: { + return sendVerificationCode(InteractionEvent.ForgotPassword, identifier); + } + case UserFlow.Continue: { + // Continue flow does not have its own email template, always use sign-in template for now + return sendVerificationCode(InteractionEvent.SignIn, identifier); + } } - - return sendVerificationCode(payload); }; diff --git a/packages/experience/src/components/IdentifierRegisterForm/index.test.tsx b/packages/experience/src/components/IdentifierRegisterForm/index.test.tsx index ee893b0f879..5678678ab65 100644 --- a/packages/experience/src/components/IdentifierRegisterForm/index.test.tsx +++ b/packages/experience/src/components/IdentifierRegisterForm/index.test.tsx @@ -8,7 +8,7 @@ import UserInteractionContextProvider from '@/Providers/UserInteractionContextPr import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; -import { registerWithUsernamePassword } from '@/apis/interaction'; +import { registerWithUsername } from '@/apis/experience'; import { sendVerificationCodeApi } from '@/apis/utils'; import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; import { UserFlow } from '@/types'; @@ -34,12 +34,9 @@ jest.mock('@/apis/utils', () => ({ sendVerificationCodeApi: jest.fn(), })); -jest.mock('@/apis/interaction', () => ({ - registerWithUsernamePassword: jest.fn(async () => ({})), -})); - -jest.mock('@/apis/single-sign-on', () => ({ - getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email), +jest.mock('@/apis/experience', () => ({ + registerWithUsername: jest.fn(async () => ({})), + getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email), })); const renderForm = ( @@ -100,7 +97,7 @@ describe('', () => { await waitFor(() => { expect(queryByText('error.general_required')).not.toBeNull(); - expect(registerWithUsernamePassword).not.toBeCalled(); + expect(registerWithUsername).not.toBeCalled(); expect(sendVerificationCodeApi).not.toBeCalled(); }); }); @@ -121,7 +118,7 @@ describe('', () => { await waitFor(() => { expect(queryByText('error.username_should_not_start_with_number')).not.toBeNull(); - expect(registerWithUsernamePassword).not.toBeCalled(); + expect(registerWithUsername).not.toBeCalled(); }); act(() => { @@ -148,7 +145,7 @@ describe('', () => { await waitFor(() => { expect(queryByText('error.username_invalid_charset')).not.toBeNull(); - expect(registerWithUsernamePassword).not.toBeCalled(); + expect(registerWithUsername).not.toBeCalled(); }); act(() => { @@ -176,7 +173,7 @@ describe('', () => { await waitFor(() => { expect(queryByText('description.agree_with_terms_modal')).not.toBeNull(); - expect(registerWithUsernamePassword).not.toBeCalled(); + expect(registerWithUsername).not.toBeCalled(); }); act(() => { @@ -188,7 +185,7 @@ describe('', () => { }); await waitFor(() => { - expect(registerWithUsernamePassword).toBeCalledWith('username'); + expect(registerWithUsername).toBeCalledWith('username'); }); }); }); @@ -211,7 +208,7 @@ describe('', () => { await waitFor(() => { expect(queryByText('error.invalid_email')).not.toBeNull(); - expect(registerWithUsernamePassword).not.toBeCalled(); + expect(registerWithUsername).not.toBeCalled(); expect(sendVerificationCodeApi).not.toBeCalled(); }); @@ -244,10 +241,15 @@ describe('', () => { }); await waitFor(() => { - expect(registerWithUsernamePassword).not.toBeCalled(); - expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { - email: 'foo@logto.io', - }); + expect(registerWithUsername).not.toBeCalled(); + expect(sendVerificationCodeApi).toBeCalledWith( + UserFlow.Register, + { + type: SignInIdentifier.Email, + value: 'foo@logto.io', + }, + undefined + ); }); }); } @@ -271,7 +273,7 @@ describe('', () => { await waitFor(() => { expect(queryByText('error.invalid_phone')).not.toBeNull(); - expect(registerWithUsernamePassword).not.toBeCalled(); + expect(registerWithUsername).not.toBeCalled(); expect(sendVerificationCodeApi).not.toBeCalled(); }); @@ -303,10 +305,15 @@ describe('', () => { }); await waitFor(() => { - expect(registerWithUsernamePassword).not.toBeCalled(); - expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { - phone: `${getDefaultCountryCallingCode()}8573333333`, - }); + expect(registerWithUsername).not.toBeCalled(); + expect(sendVerificationCodeApi).toBeCalledWith( + UserFlow.Register, + { + type: SignInIdentifier.Phone, + value: `${getDefaultCountryCallingCode()}8573333333`, + }, + undefined + ); }); }); } @@ -344,9 +351,14 @@ describe('', () => { await waitFor(() => { expect(getSingleSignOnConnectorsMock).not.toBeCalled(); - expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { - email, - }); + expect(sendVerificationCodeApi).toBeCalledWith( + UserFlow.Register, + { + type: SignInIdentifier.Email, + value: email, + }, + undefined + ); }); }); @@ -380,14 +392,21 @@ describe('', () => { expect(queryByText('action.single_sign_on')).toBeNull(); await waitFor(() => { - expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { - email, - }); + expect(sendVerificationCodeApi).toBeCalledWith( + UserFlow.Register, + { + type: SignInIdentifier.Email, + value: email, + }, + undefined + ); }); }); it('should call check single sign-on connector when the identifier is email, and goes to the SSO flow', async () => { - getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id)); + getSingleSignOnConnectorsMock.mockResolvedValueOnce({ + connectorIds: mockSsoConnectors.map(({ id }) => id), + }); const { getByText, container, queryByText } = renderForm( [SignInIdentifier.Email], diff --git a/packages/experience/src/components/IdentifierSignInForm/index.test.tsx b/packages/experience/src/components/IdentifierSignInForm/index.test.tsx index 662cda6d885..b2b1eb10ac1 100644 --- a/packages/experience/src/components/IdentifierSignInForm/index.test.tsx +++ b/packages/experience/src/components/IdentifierSignInForm/index.test.tsx @@ -36,8 +36,8 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockedNavigate, })); -jest.mock('@/apis/single-sign-on', () => ({ - getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email), +jest.mock('@/apis/experience', () => ({ + getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email), })); const username = 'foo'; @@ -151,12 +151,17 @@ describe('IdentifierSignInForm', () => { if (verificationCode) { await waitFor(() => { - expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, { - [identifier]: - identifier === SignInIdentifier.Phone - ? `${getDefaultCountryCallingCode()}${value}` - : value, - }); + expect(sendVerificationCodeApi).toBeCalledWith( + UserFlow.SignIn, + { + type: identifier, + value: + identifier === SignInIdentifier.Phone + ? `${getDefaultCountryCallingCode()}${value}` + : value, + }, + undefined + ); expect(mockedNavigate).not.toBeCalled(); }); } @@ -221,7 +226,7 @@ describe('IdentifierSignInForm', () => { }); it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => { - getSingleSignOnConnectorsMock.mockResolvedValueOnce([]); + getSingleSignOnConnectorsMock.mockResolvedValueOnce({ connectorIds: [] }); const { getByText, container, queryByText } = renderForm( mockSignInMethodSettingsTestCases[0]!, @@ -255,7 +260,9 @@ describe('IdentifierSignInForm', () => { }); it('should call check single sign-on connector when the identifier is email, and process to single sign-on if a sso connector is matched', async () => { - getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id)); + getSingleSignOnConnectorsMock.mockResolvedValueOnce({ + connectorIds: mockSsoConnectors.map(({ id }) => id), + }); const { getByText, container, queryByText } = renderForm( mockSignInMethodSettingsTestCases[0]!, diff --git a/packages/experience/src/components/PasswordSignInForm/index.test.tsx b/packages/experience/src/components/PasswordSignInForm/index.test.tsx index 5d55680c7cc..97d65394d9a 100644 --- a/packages/experience/src/components/PasswordSignInForm/index.test.tsx +++ b/packages/experience/src/components/PasswordSignInForm/index.test.tsx @@ -8,13 +8,12 @@ import UserInteractionContextProvider from '@/Providers/UserInteractionContextPr import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; -import { signInWithPasswordIdentifier } from '@/apis/interaction'; +import { signInWithPasswordIdentifier } from '@/apis/experience'; import type { SignInExperienceResponse } from '@/types'; import { getDefaultCountryCallingCode } from '@/utils/country-code'; import PasswordSignInForm from '.'; -jest.mock('@/apis/interaction', () => ({ signInWithPasswordIdentifier: jest.fn(async () => 0) })); jest.mock('react-device-detect', () => ({ isMobile: true, })); @@ -29,9 +28,10 @@ jest.mock('i18next', () => ({ t: (key: string) => key, })); -jest.mock('@/apis/single-sign-on', () => ({ - getSingleSignOnUrl: (connectorId: string) => getSingleSignOnUrlMock(connectorId), - getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email), +jest.mock('@/apis/experience', () => ({ + signInWithPasswordIdentifier: jest.fn(async () => 0), + getSsoAuthorizationUrl: (connectorId: string) => getSingleSignOnUrlMock(connectorId), + getSsoConnectors: (email: string) => getSingleSignOnConnectorsMock(email), })); jest.mock('react-router-dom', () => ({ @@ -175,10 +175,13 @@ describe('UsernamePasswordSignInForm', () => { await waitFor(() => { expect(signInWithPasswordIdentifier).toBeCalledWith({ - [type]: - type === SignInIdentifier.Phone - ? `${getDefaultCountryCallingCode()}${identifier}` - : identifier, + identifier: { + type, + value: + type === SignInIdentifier.Phone + ? `${getDefaultCountryCallingCode()}${identifier}` + : identifier, + }, password: 'password', }); }); @@ -224,7 +227,7 @@ describe('UsernamePasswordSignInForm', () => { // Valid email with empty response const email = 'foo@logto.io'; - getSingleSignOnConnectorsMock.mockResolvedValueOnce([]); + getSingleSignOnConnectorsMock.mockResolvedValueOnce({ connectorIds: [] }); act(() => { fireEvent.change(identifierInput, { target: { value: email } }); }); @@ -238,7 +241,9 @@ describe('UsernamePasswordSignInForm', () => { // Valid email with response const email2 = 'foo@bar.io'; getSingleSignOnConnectorsMock.mockClear(); - getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id)); + getSingleSignOnConnectorsMock.mockResolvedValueOnce({ + connectorIds: mockSsoConnectors.map(({ id }) => id), + }); act(() => { fireEvent.change(identifierInput, { target: { value: email2 } }); @@ -282,7 +287,9 @@ describe('UsernamePasswordSignInForm', () => { const email = 'foo@bar.io'; getSingleSignOnConnectorsMock.mockClear(); - getSingleSignOnConnectorsMock.mockResolvedValueOnce([mockSsoConnectors[0]!.id]); + getSingleSignOnConnectorsMock.mockResolvedValueOnce({ + connectorIds: [mockSsoConnectors[0]!.id], + }); act(() => { fireEvent.change(identifierInput, { target: { value: email } }); diff --git a/packages/experience/src/containers/SocialLinkAccount/index.test.tsx b/packages/experience/src/containers/SocialLinkAccount/index.test.tsx index 98950a5ee30..685c86cf2f8 100644 --- a/packages/experience/src/containers/SocialLinkAccount/index.test.tsx +++ b/packages/experience/src/containers/SocialLinkAccount/index.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, waitFor } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings } from '@/__mocks__/logto'; -import { registerWithVerifiedSocial, bindSocialRelatedUser } from '@/apis/interaction'; +import { bindSocialRelatedUser, registerWithVerifiedIdentifier } from '@/apis/experience'; import SocialLinkAccount from '.'; @@ -15,13 +15,14 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); -jest.mock('@/apis/interaction', () => ({ - registerWithVerifiedSocial: jest.fn(async () => ({ redirectTo: '/' })), +jest.mock('@/apis/experience', () => ({ + registerWithVerifiedIdentifier: jest.fn(async () => ({ redirectTo: '/' })), bindSocialRelatedUser: jest.fn(async () => ({ redirectTo: '/' })), })); describe('SocialLinkAccount', () => { const relatedUser = Object.freeze({ type: 'email', value: 'foo@logto.io' }); + const verificationId = 'foo'; afterEach(() => { jest.clearAllMocks(); @@ -30,7 +31,11 @@ describe('SocialLinkAccount', () => { it('should render bindUser Button', async () => { const { getByText } = renderWithPageContext( - + ); const bindButton = getByText('action.bind'); @@ -39,10 +44,7 @@ describe('SocialLinkAccount', () => { fireEvent.click(bindButton); }); - expect(bindSocialRelatedUser).toBeCalledWith({ - connectorId: 'github', - email: 'foo@logto.io', - }); + expect(bindSocialRelatedUser).toBeCalledWith(verificationId); }); it('should render link email with email signUp identifier', () => { @@ -57,7 +59,11 @@ describe('SocialLinkAccount', () => { }, }} > - + ); @@ -77,7 +83,11 @@ describe('SocialLinkAccount', () => { }, }} > - + ); @@ -97,7 +107,11 @@ describe('SocialLinkAccount', () => { }, }} > - + ); @@ -108,7 +122,11 @@ describe('SocialLinkAccount', () => { it('should call registerWithVerifiedSocial when click create button', async () => { const { getByText } = renderWithPageContext( - + ); const createButton = getByText('action.create_account_without_linking'); @@ -117,6 +135,6 @@ describe('SocialLinkAccount', () => { fireEvent.click(createButton); }); - expect(registerWithVerifiedSocial).toBeCalledWith('github'); + expect(registerWithVerifiedIdentifier).toBeCalledWith(verificationId); }); }); diff --git a/packages/experience/src/containers/SocialLinkAccount/index.tsx b/packages/experience/src/containers/SocialLinkAccount/index.tsx index 5733b65b283..118ed73fa4e 100644 --- a/packages/experience/src/containers/SocialLinkAccount/index.tsx +++ b/packages/experience/src/containers/SocialLinkAccount/index.tsx @@ -17,6 +17,7 @@ import useBindSocialRelatedUser from './use-social-link-related-user'; type Props = { readonly className?: string; readonly connectorId: string; + readonly verificationId: string; readonly relatedUser: SocialRelatedUserInfo; }; @@ -39,7 +40,7 @@ const getCreateAccountActionText = (signUpMethods: string[]): TFuncKey => { return 'action.create_account_without_linking'; }; -const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { +const SocialLinkAccount = ({ connectorId, verificationId, className, relatedUser }: Props) => { const { t } = useTranslation(); const { signUpMethods } = useSieMethods(); @@ -58,10 +59,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { title="action.bind" i18nProps={{ address: type === 'email' ? maskEmail(value) : maskPhone(value) }} onClick={() => { - void bindSocialRelatedUser({ - connectorId, - ...(type === 'email' ? { email: value } : { phone: value }), - }); + void bindSocialRelatedUser(verificationId); }} /> @@ -72,7 +70,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { { - void registerWithSocial(connectorId); + void registerWithSocial(verificationId); }} /> diff --git a/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts b/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts index dd035d4931e..1bf7f188b81 100644 --- a/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts +++ b/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { bindSocialRelatedUser } from '@/apis/interaction'; +import { bindSocialRelatedUser } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; diff --git a/packages/experience/src/containers/SocialSignInList/use-social.ts b/packages/experience/src/containers/SocialSignInList/use-social.ts index 3ea08c1103f..e97b58ebf89 100644 --- a/packages/experience/src/containers/SocialSignInList/use-social.ts +++ b/packages/experience/src/containers/SocialSignInList/use-social.ts @@ -1,12 +1,14 @@ import { AgreeToTermsPolicy, ConnectorPlatform, + VerificationType, type ExperienceSocialConnector, } from '@logto/schemas'; import { useCallback, useContext } from 'react'; import PageContext from '@/Providers/PageContextProvider/PageContext'; -import { getSocialAuthorizationUrl } from '@/apis/interaction'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { getSocialAuthorizationUrl } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; @@ -20,6 +22,8 @@ const useSocial = () => { const handleError = useErrorHandler(); const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl); const { termsValidation, agreeToTermsPolicy } = useTerms(); + const { setVerificationId } = useContext(UserInteractionContext); + const redirectTo = useGlobalRedirectTo({ shouldClearInteractionContextSession: false, isReplace: false, @@ -69,19 +73,23 @@ const useSocial = () => { return; } - if (!result?.redirectTo) { + if (!result) { return; } + const { verificationId, authorizationUri } = result; + + setVerificationId(VerificationType.Social, verificationId); + // Invoke native social sign-in flow if (isNativeWebview()) { - nativeSignInHandler(result.redirectTo, connector); + nativeSignInHandler(authorizationUri, connector); return; } // Invoke web social sign-in flow - await redirectTo(result.redirectTo); + await redirectTo(authorizationUri); }, [ agreeToTermsPolicy, @@ -89,6 +97,7 @@ const useSocial = () => { handleError, nativeSignInHandler, redirectTo, + setVerificationId, termsValidation, ] ); diff --git a/packages/experience/src/containers/TotpCodeVerification/index.tsx b/packages/experience/src/containers/TotpCodeVerification/index.tsx index a15080bb5aa..343ee83a2ea 100644 --- a/packages/experience/src/containers/TotpCodeVerification/index.tsx +++ b/packages/experience/src/containers/TotpCodeVerification/index.tsx @@ -14,11 +14,16 @@ const isCodeReady = (code: string[]) => { return code.length === totpCodeLength && code.every(Boolean); }; -type Props = { - readonly flow: UserMfaFlow; -}; +type Props = T extends UserMfaFlow.MfaBinding + ? { + flow: T; + verificationId: string; + } + : { + flow: T; + }; -const TotpCodeVerification = ({ flow }: Props) => { +const TotpCodeVerification = (props: Props) => { const { t } = useTranslation(); const [codeInput, setCodeInput] = useState([]); @@ -29,10 +34,7 @@ const TotpCodeVerification = ({ flow }: Props) => { setInputErrorMessage(undefined); }, []); - const { errorMessage: submitErrorMessage, onSubmit } = useTotpCodeVerification( - flow, - errorCallback - ); + const { errorMessage: submitErrorMessage, onSubmit } = useTotpCodeVerification(errorCallback); const [isSubmitting, setIsSubmitting] = useState(false); @@ -42,10 +44,11 @@ const TotpCodeVerification = ({ flow }: Props) => { async (code: string[]) => { setInputErrorMessage(undefined); setIsSubmitting(true); - await onSubmit(code.join('')); + + await onSubmit(code.join(''), props); setIsSubmitting(false); }, - [onSubmit] + [onSubmit, props] ); return ( diff --git a/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts b/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts index 3c05c6e9384..7ae226a7471 100644 --- a/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts +++ b/packages/experience/src/containers/TotpCodeVerification/use-totp-code-verification.ts @@ -5,7 +5,7 @@ import { type ErrorHandlers } from '@/hooks/use-error-handler'; import useSendMfaPayload from '@/hooks/use-send-mfa-payload'; import { type UserMfaFlow } from '@/types'; -const useTotpCodeVerification = (flow: UserMfaFlow, errorCallback?: () => void) => { +const useTotpCodeVerification = (errorCallback?: () => void) => { const [errorMessage, setErrorMessage] = useState(); const sendMfaPayload = useSendMfaPayload(); @@ -19,14 +19,19 @@ const useTotpCodeVerification = (flow: UserMfaFlow, errorCallback?: () => void) ); const onSubmit = useCallback( - async (code: string) => { + async ( + code: string, + payload: + | { flow: UserMfaFlow.MfaBinding; verificationId: string } + | { flow: UserMfaFlow.MfaVerification } + ) => { await sendMfaPayload( - { flow, payload: { type: MfaFactor.TOTP, code } }, + { payload: { type: MfaFactor.TOTP, code }, ...payload }, invalidCodeErrorHandlers, errorCallback ); }, - [errorCallback, flow, invalidCodeErrorHandlers, sendMfaPayload] + [errorCallback, invalidCodeErrorHandlers, sendMfaPayload] ); return { diff --git a/packages/experience/src/containers/VerificationCode/index.test.tsx b/packages/experience/src/containers/VerificationCode/index.test.tsx index 45dfbb1a95c..ee37b49c0e2 100644 --- a/packages/experience/src/containers/VerificationCode/index.test.tsx +++ b/packages/experience/src/containers/VerificationCode/index.test.tsx @@ -1,14 +1,14 @@ import resource from '@logto/phrases-experience'; -import { SignInIdentifier } from '@logto/schemas'; +import { + InteractionEvent, + SignInIdentifier, + type VerificationCodeIdentifier, +} from '@logto/schemas'; import { act, fireEvent, waitFor } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; -import { - verifyForgotPasswordVerificationCodeIdentifier, - signInWithVerificationCodeIdentifier, - addProfileWithVerificationCodeIdentifier, -} from '@/apis/interaction'; -import { sendVerificationCodeApi } from '@/apis/utils'; +import { identifyWithVerificationCode, updateProfileWithVerificationCode } from '@/apis/experience'; +import { resendVerificationCodeApi } from '@/apis/utils'; import { setupI18nForTesting } from '@/jest.setup'; import { UserFlow } from '@/types'; @@ -21,22 +21,39 @@ const mockedNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockedNavigate, + useLocation: jest.fn(() => ({ + state: { + interactionEvent: InteractionEvent.SignIn, + }, + })), })); jest.mock('@/apis/utils', () => ({ sendVerificationCodeApi: jest.fn(), + resendVerificationCodeApi: jest.fn(), })); -jest.mock('@/apis/interaction', () => ({ - verifyForgotPasswordVerificationCodeIdentifier: jest.fn(), - signInWithVerificationCodeIdentifier: jest.fn(), - addProfileWithVerificationCodeIdentifier: jest.fn(), +jest.mock('@/apis/experience', () => ({ + identifyWithVerificationCode: jest.fn().mockResolvedValue({ redirectTo: '/redirect' }), + updateProfileWithVerificationCode: jest.fn().mockResolvedValue({ redirectTo: '/redirect' }), })); describe('', () => { + const redirectTo = '/redirect'; const email = 'foo@logto.io'; const phone = '18573333333'; const originalLocation = window.location; + const verificationId = '123456'; + + const emailIdentifier: VerificationCodeIdentifier = { + type: SignInIdentifier.Email, + value: email, + }; + + const phoneIdentifier: VerificationCodeIdentifier = { + type: SignInIdentifier.Phone, + value: phone, + }; beforeAll(() => { // eslint-disable-next-line @silverhand/fp/no-mutating-methods @@ -47,7 +64,7 @@ describe('', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); afterAll(() => { @@ -58,7 +75,11 @@ describe('', () => { it('render counter', () => { const { queryByText } = renderWithPageContext( - + ); expect(queryByText('description.resend_after_seconds')).not.toBeNull(); @@ -87,7 +108,11 @@ describe('', () => { }); const { getByText } = renderWithPageContext( - + ); act(() => { jest.advanceTimersByTime(1e3 * 60); @@ -98,7 +123,7 @@ describe('', () => { fireEvent.click(resendButton); }); - expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, { email }); + expect(resendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, emailIdentifier); // Reset i18n await setupI18nForTesting(); @@ -106,15 +131,11 @@ describe('', () => { describe('sign-in', () => { it('fire email sign-in validate verification code event', async () => { - (signInWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({ - redirectTo: 'foo.com', - })); - const { container } = renderWithPageContext( ); const inputs = container.querySelectorAll('input'); @@ -126,27 +147,24 @@ describe('', () => { } await waitFor(() => { - expect(signInWithVerificationCodeIdentifier).toBeCalledWith({ - email, - verificationCode: '111111', + expect(identifyWithVerificationCode).toBeCalledWith({ + identifier: emailIdentifier, + verificationId, + code: '111111', }); }); await waitFor(() => { - expect(window.location.replace).toBeCalledWith('foo.com'); + expect(window.location.replace).toBeCalledWith(redirectTo); }); }); it('fire phone sign-in validate verification code event', async () => { - (signInWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({ - redirectTo: 'foo.com', - })); - const { container } = renderWithPageContext( ); const inputs = container.querySelectorAll('input'); @@ -158,29 +176,26 @@ describe('', () => { } await waitFor(() => { - expect(signInWithVerificationCodeIdentifier).toBeCalledWith({ - phone, - verificationCode: '111111', + expect(identifyWithVerificationCode).toBeCalledWith({ + identifier: phoneIdentifier, + verificationId, + code: '111111', }); }); await waitFor(() => { - expect(window.location.replace).toBeCalledWith('foo.com'); + expect(window.location.replace).toBeCalledWith(redirectTo); }); }); }); describe('register', () => { it('fire email register validate verification code event', async () => { - (addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({ - redirectTo: 'foo.com', - })); - const { container } = renderWithPageContext( ); const inputs = container.querySelectorAll('input'); @@ -192,27 +207,24 @@ describe('', () => { } await waitFor(() => { - expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({ - email, - verificationCode: '111111', + expect(identifyWithVerificationCode).toBeCalledWith({ + identifier: emailIdentifier, + verificationId, + code: '111111', }); }); await waitFor(() => { - expect(window.location.replace).toBeCalledWith('foo.com'); + expect(window.location.replace).toBeCalledWith(redirectTo); }); }); it('fire phone register validate verification code event', async () => { - (addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({ - redirectTo: 'foo.com', - })); - const { container } = renderWithPageContext( ); const inputs = container.querySelectorAll('input'); @@ -224,29 +236,26 @@ describe('', () => { } await waitFor(() => { - expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({ - phone, - verificationCode: '111111', + expect(identifyWithVerificationCode).toBeCalledWith({ + identifier: phoneIdentifier, + verificationId, + code: '111111', }); }); await waitFor(() => { - expect(window.location.replace).toBeCalledWith('foo.com'); + expect(window.location.replace).toBeCalledWith(redirectTo); }); }); }); describe('forgot password', () => { it('fire email forgot-password validate verification code event', async () => { - (verifyForgotPasswordVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({ - success: true, - })); - const { container } = renderWithPageContext( ); @@ -259,23 +268,20 @@ describe('', () => { } await waitFor(() => { - expect(verifyForgotPasswordVerificationCodeIdentifier).toBeCalledWith({ - email, - verificationCode: '111111', + expect(identifyWithVerificationCode).toBeCalledWith({ + identifier: emailIdentifier, + verificationId, + code: '111111', }); }); }); it('fire phone forgot-password validate verification code event', async () => { - (verifyForgotPasswordVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({ - success: true, - })); - const { container } = renderWithPageContext( ); @@ -288,9 +294,10 @@ describe('', () => { } await waitFor(() => { - expect(verifyForgotPasswordVerificationCodeIdentifier).toBeCalledWith({ - phone, - verificationCode: '111111', + expect(identifyWithVerificationCode).toBeCalledWith({ + identifier: phoneIdentifier, + verificationId, + code: '111111', }); }); }); @@ -298,15 +305,11 @@ describe('', () => { describe('continue flow', () => { it('set email', async () => { - (addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({ - redirectTo: '/redirect', - })); - const { container } = renderWithPageContext( ); @@ -319,27 +322,27 @@ describe('', () => { } await waitFor(() => { - expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({ - email, - verificationCode: '111111', - }); + expect(updateProfileWithVerificationCode).toBeCalledWith( + { + identifier: emailIdentifier, + verificationId, + code: '111111', + }, + InteractionEvent.SignIn + ); }); await waitFor(() => { - expect(window.location.replace).toBeCalledWith('/redirect'); + expect(window.location.replace).toBeCalledWith(redirectTo); }); }); it('set Phone', async () => { - (addProfileWithVerificationCodeIdentifier as jest.Mock).mockImplementationOnce(() => ({ - redirectTo: '/redirect', - })); - const { container } = renderWithPageContext( ); @@ -352,10 +355,14 @@ describe('', () => { } await waitFor(() => { - expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({ - phone, - verificationCode: '111111', - }); + expect(updateProfileWithVerificationCode).toBeCalledWith( + { + identifier: phoneIdentifier, + verificationId, + code: '111111', + }, + InteractionEvent.SignIn + ); }); await waitFor(() => { diff --git a/packages/experience/src/containers/VerificationCode/index.tsx b/packages/experience/src/containers/VerificationCode/index.tsx index 26ee193fe9d..10f9ebd88df 100644 --- a/packages/experience/src/containers/VerificationCode/index.tsx +++ b/packages/experience/src/containers/VerificationCode/index.tsx @@ -1,4 +1,4 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { type VerificationCodeIdentifier } from '@logto/schemas'; import classNames from 'classnames'; import { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation, Trans } from 'react-i18next'; @@ -15,13 +15,19 @@ import { getCodeVerificationHookByFlow } from './utils'; type Props = { readonly flow: UserFlow; - readonly identifier: SignInIdentifier.Email | SignInIdentifier.Phone; - readonly target: string; + readonly identifier: VerificationCodeIdentifier; + readonly verificationId: string; readonly hasPasswordButton?: boolean; readonly className?: string; }; -const VerificationCode = ({ flow, identifier, className, hasPasswordButton, target }: Props) => { +const VerificationCode = ({ + flow, + identifier, + verificationId, + className, + hasPasswordButton, +}: Props) => { const [codeInput, setCodeInput] = useState([]); const [inputErrorMessage, setInputErrorMessage] = useState(); @@ -43,14 +49,13 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ errorMessage: submitErrorMessage, clearErrorMessage, onSubmit, - } = useVerificationCode(identifier, target, errorCallback); + } = useVerificationCode(identifier, verificationId, errorCallback); const errorMessage = inputErrorMessage ?? submitErrorMessage; const { seconds, isRunning, onResendVerificationCode } = useResendVerificationCode( flow, - identifier, - target + identifier ); const [isSubmitting, setIsSubmitting] = useState(false); @@ -61,15 +66,11 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ setIsSubmitting(true); - await onSubmit( - identifier === SignInIdentifier.Email - ? { email: target, verificationCode: code.join('') } - : { phone: target, verificationCode: code.join('') } - ); + await onSubmit(code.join('')); setIsSubmitting(false); }, - [identifier, onSubmit, target] + [onSubmit] ); useEffect(() => { diff --git a/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts index 946d42cfa43..51aea127223 100644 --- a/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts @@ -1,74 +1,88 @@ -import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas'; -import { SignInIdentifier } from '@logto/schemas'; -import { useCallback, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; - -import { addProfileWithVerificationCodeIdentifier } from '@/apis/interaction'; +import type { VerificationCodeIdentifier } from '@logto/schemas'; +import { VerificationType } from '@logto/schemas'; +import { useCallback, useContext, useMemo } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { validate } from 'superstruct'; + +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { updateProfileWithVerificationCode } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; -import type { VerificationCodeIdentifier } from '@/types'; import { SearchParameters } from '@/types'; +import { continueFlowStateGuard } from '@/types/guard'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; import useLinkSocialConfirmModal from './use-link-social-confirm-modal'; const useContinueFlowCodeVerification = ( - _method: VerificationCodeIdentifier, - target: string, + identifier: VerificationCodeIdentifier, + verificationId: string, errorCallback?: () => void ) => { const [searchParameters] = useSearchParams(); const redirectTo = useGlobalRedirectTo(); + const { state } = useLocation(); + const [, continueFlowState] = validate(state, continueFlowStateGuard); + const { verificationIdsMap } = useContext(UserInteractionContext); + const interactionEvent = continueFlowState?.interactionEvent; + const handleError = useErrorHandler(); - const verifyVerificationCode = useApi(addProfileWithVerificationCodeIdentifier); + const verifyVerificationCode = useApi(updateProfileWithVerificationCode); const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } = useGeneralVerificationCodeErrorHandler(); - const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); + + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true, interactionEvent }); const showIdentifierErrorAlert = useIdentifierErrorAlert(); const showLinkSocialConfirmModal = useLinkSocialConfirmModal(); - const identifierExistErrorHandler = useCallback( - async (method: VerificationCodeIdentifier, target: string) => { - const linkSocial = searchParameters.get(SearchParameters.LinkSocial); - // Show bind with social confirm modal - if (linkSocial) { - await showLinkSocialConfirmModal(method, target, linkSocial); - - return; - } - - await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, method, target); - }, - [searchParameters, showIdentifierErrorAlert, showLinkSocialConfirmModal] - ); + const identifierExistErrorHandler = useCallback(async () => { + const linkSocial = searchParameters.get(SearchParameters.LinkSocial); + const socialVerificationId = verificationIdsMap[VerificationType.Social]; + + // Show bind with social confirm modal + if (linkSocial && socialVerificationId) { + await showLinkSocialConfirmModal(identifier, verificationId, socialVerificationId); + + return; + } + const { type, value } = identifier; + await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, type, value); + }, [ + identifier, + searchParameters, + showIdentifierErrorAlert, + showLinkSocialConfirmModal, + verificationId, + verificationIdsMap, + ]); const verifyVerificationCodeErrorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.phone_already_in_use': async () => - identifierExistErrorHandler(SignInIdentifier.Phone, target), - 'user.email_already_in_use': async () => - identifierExistErrorHandler(SignInIdentifier.Email, target), + 'user.phone_already_in_use': identifierExistErrorHandler, + 'user.email_already_in_use': identifierExistErrorHandler, ...preSignInErrorHandler, ...generalVerificationCodeErrorHandlers, }), - [ - preSignInErrorHandler, - generalVerificationCodeErrorHandlers, - identifierExistErrorHandler, - target, - ] + [preSignInErrorHandler, generalVerificationCodeErrorHandlers, identifierExistErrorHandler] ); const onSubmit = useCallback( - async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => { - const [error, result] = await verifyVerificationCode(payload); + async (code: string) => { + const [error, result] = await verifyVerificationCode( + { + code, + identifier, + verificationId, + }, + interactionEvent + ); if (error) { await handleError(error, verifyVerificationCodeErrorHandlers); @@ -84,7 +98,10 @@ const useContinueFlowCodeVerification = ( [ errorCallback, handleError, + identifier, + interactionEvent, redirectTo, + verificationId, verifyVerificationCode, verifyVerificationCodeErrorHandlers, ] diff --git a/packages/experience/src/containers/VerificationCode/use-forgot-password-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-forgot-password-flow-code-verification.ts index ee9c4a3084f..6775fc63f8b 100644 --- a/packages/experience/src/containers/VerificationCode/use-forgot-password-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-forgot-password-flow-code-verification.ts @@ -1,25 +1,24 @@ -import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas'; -import { useMemo, useCallback } from 'react'; +import type { VerificationCodeIdentifier } from '@logto/schemas'; +import { useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { verifyForgotPasswordVerificationCodeIdentifier } from '@/apis/interaction'; +import { identifyWithVerificationCode } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; -import type { VerificationCodeIdentifier } from '@/types'; import { UserFlow } from '@/types'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; const useForgotPasswordFlowCodeVerification = ( - method: VerificationCodeIdentifier, - target: string, + identifier: VerificationCodeIdentifier, + verificationId: string, errorCallback?: () => void ) => { const navigate = useNavigate(); const handleError = useErrorHandler(); - const verifyVerificationCode = useApi(verifyForgotPasswordVerificationCodeIdentifier); + const verifyVerificationCode = useApi(identifyWithVerificationCode); const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } = useGeneralVerificationCodeErrorHandler(); @@ -28,18 +27,32 @@ const useForgotPasswordFlowCodeVerification = ( const errorHandlers: ErrorHandlers = useMemo( () => ({ 'user.user_not_exist': async () => - identifierErrorHandler(IdentifierErrorType.IdentifierNotExist, method, target), + identifierErrorHandler( + IdentifierErrorType.IdentifierNotExist, + identifier.type, + identifier.value + ), 'user.new_password_required_in_profile': () => { navigate(`/${UserFlow.ForgotPassword}/reset`, { replace: true }); }, ...generalVerificationCodeErrorHandlers, }), - [generalVerificationCodeErrorHandlers, identifierErrorHandler, method, target, navigate] + [ + generalVerificationCodeErrorHandlers, + identifierErrorHandler, + identifier.type, + identifier.value, + navigate, + ] ); const onSubmit = useCallback( - async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => { - const [error, result] = await verifyVerificationCode(payload); + async (code: string) => { + const [error, result] = await verifyVerificationCode({ + code, + identifier, + verificationId, + }); if (error) { await handleError(error, errorHandlers); @@ -52,7 +65,15 @@ const useForgotPasswordFlowCodeVerification = ( navigate(`/${UserFlow.SignIn}`, { replace: true }); } }, - [errorCallback, errorHandlers, handleError, navigate, verifyVerificationCode] + [ + errorCallback, + errorHandlers, + handleError, + identifier, + navigate, + verificationId, + verifyVerificationCode, + ] ); return { diff --git a/packages/experience/src/containers/VerificationCode/use-link-social-confirm-modal.ts b/packages/experience/src/containers/VerificationCode/use-link-social-confirm-modal.ts index 210ea554115..717c1f38aaa 100644 --- a/packages/experience/src/containers/VerificationCode/use-link-social-confirm-modal.ts +++ b/packages/experience/src/containers/VerificationCode/use-link-social-confirm-modal.ts @@ -1,11 +1,11 @@ import { SignInIdentifier } from '@logto/schemas'; +import type { VerificationCodeIdentifier } from '@logto/schemas'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; import useLinkSocial from '@/hooks/use-social-link-account'; -import type { VerificationCodeIdentifier } from '@/types'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; const useLinkSocialConfirmModal = () => { @@ -15,22 +15,28 @@ const useLinkSocialConfirmModal = () => { const navigate = useNavigate(); return useCallback( - async (method: VerificationCodeIdentifier, target: string, connectorId: string) => { + async ( + identifier: VerificationCodeIdentifier, + identifierVerificationId: string, + socialVerificationId: string + ) => { + const { type, value } = identifier; + show({ confirmText: 'action.bind_and_continue', cancelText: 'action.change', cancelTextI18nProps: { - method: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`), + method: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`), }, ModalContent: t('description.link_account_id_exists', { - type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`), + type: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`), value: - method === SignInIdentifier.Phone - ? formatPhoneNumberWithCountryCallingCode(target) - : target, + type === SignInIdentifier.Phone + ? formatPhoneNumberWithCountryCallingCode(value) + : value, }), onConfirm: async () => { - await linkWithSocial(connectorId); + await linkWithSocial(identifierVerificationId, socialVerificationId); }, onCancel: () => { navigate(-1); diff --git a/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts index 5df3843d85f..de2a1aedb68 100644 --- a/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts @@ -1,13 +1,14 @@ -import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas'; -import { SignInIdentifier, SignInMode } from '@logto/schemas'; +import { + InteractionEvent, + SignInIdentifier, + SignInMode, + type VerificationCodeIdentifier, +} from '@logto/schemas'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { - addProfileWithVerificationCodeIdentifier, - signInWithVerifiedIdentifier, -} from '@/apis/interaction'; +import { identifyWithVerificationCode, signInWithVerifiedIdentifier } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; @@ -15,15 +16,14 @@ import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import { useSieMethods } from '@/hooks/use-sie'; -import type { VerificationCodeIdentifier } from '@/types'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; const useRegisterFlowCodeVerification = ( - method: VerificationCodeIdentifier, - target: string, + identifier: VerificationCodeIdentifier, + verificationId: string, errorCallback?: () => void ) => { const { t } = useTranslation(); @@ -34,18 +34,30 @@ const useRegisterFlowCodeVerification = ( const { signInMode } = useSieMethods(); const handleError = useErrorHandler(); + const signInWithIdentifierAsync = useApi(signInWithVerifiedIdentifier); - const verifyVerificationCode = useApi(addProfileWithVerificationCodeIdentifier); + const verifyVerificationCode = useApi(identifyWithVerificationCode); const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } = useGeneralVerificationCodeErrorHandler(); - const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); + + const preRegisterErrorHandler = usePreSignInErrorHandler({ + replace: true, + interactionEvent: InteractionEvent.Register, + }); + + const preSignInErrorHandler = usePreSignInErrorHandler({ + replace: true, + }); const showIdentifierErrorAlert = useIdentifierErrorAlert(); + const identifierExistErrorHandler = useCallback(async () => { + const { type, value } = identifier; + // Should not redirect user to sign-in if is register-only mode if (signInMode === SignInMode.Register) { - void showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, method, target); + void showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, type, value); return; } @@ -53,14 +65,12 @@ const useRegisterFlowCodeVerification = ( show({ confirmText: 'action.sign_in', ModalContent: t('description.create_account_id_exists', { - type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`), + type: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`), value: - method === SignInIdentifier.Phone - ? formatPhoneNumberWithCountryCallingCode(target) - : target, + type === SignInIdentifier.Phone ? formatPhoneNumberWithCountryCallingCode(value) : value, }), onConfirm: async () => { - const [error, result] = await signInWithIdentifierAsync(); + const [error, result] = await signInWithIdentifierAsync(verificationId); if (error) { await handleError(error, preSignInErrorHandler); @@ -78,16 +88,16 @@ const useRegisterFlowCodeVerification = ( }); }, [ handleError, - method, + identifier, navigate, - preSignInErrorHandler, redirectTo, show, showIdentifierErrorAlert, + preSignInErrorHandler, signInMode, signInWithIdentifierAsync, t, - target, + verificationId, ]); const errorHandlers = useMemo( @@ -95,20 +105,24 @@ const useRegisterFlowCodeVerification = ( 'user.email_already_in_use': identifierExistErrorHandler, 'user.phone_already_in_use': identifierExistErrorHandler, ...generalVerificationCodeErrorHandlers, - ...preSignInErrorHandler, + ...preRegisterErrorHandler, callback: errorCallback, }), [ identifierExistErrorHandler, generalVerificationCodeErrorHandlers, - preSignInErrorHandler, + preRegisterErrorHandler, errorCallback, ] ); const onSubmit = useCallback( - async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => { - const [error, result] = await verifyVerificationCode(payload); + async (code: string) => { + const [error, result] = await verifyVerificationCode({ + verificationId, + identifier, + code, + }); if (error) { await handleError(error, errorHandlers); @@ -121,7 +135,15 @@ const useRegisterFlowCodeVerification = ( await redirectTo(result.redirectTo); } }, - [errorCallback, errorHandlers, handleError, redirectTo, verifyVerificationCode] + [ + errorCallback, + errorHandlers, + handleError, + identifier, + redirectTo, + verificationId, + verifyVerificationCode, + ] ); return { diff --git a/packages/experience/src/containers/VerificationCode/use-resend-verification-code.ts b/packages/experience/src/containers/VerificationCode/use-resend-verification-code.ts index b493f0089cc..29956c66ebd 100644 --- a/packages/experience/src/containers/VerificationCode/use-resend-verification-code.ts +++ b/packages/experience/src/containers/VerificationCode/use-resend-verification-code.ts @@ -1,13 +1,15 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { type VerificationCodeIdentifier } from '@logto/schemas'; import { t } from 'i18next'; -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { useTimer } from 'react-timer-hook'; -import { sendVerificationCodeApi } from '@/apis/utils'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { resendVerificationCodeApi } from '@/apis/utils'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useToast from '@/hooks/use-toast'; import type { UserFlow } from '@/types'; +import { codeVerificationTypeMap } from '@/utils/sign-in-experience'; export const timeRange = 59; @@ -18,11 +20,7 @@ const getTimeout = () => { return now; }; -const useResendVerificationCode = ( - type: UserFlow, - method: SignInIdentifier.Email | SignInIdentifier.Phone, - target: string -) => { +const useResendVerificationCode = (flow: UserFlow, identifier: VerificationCodeIdentifier) => { const { setToast } = useToast(); const { seconds, isRunning, restart } = useTimer({ @@ -31,11 +29,11 @@ const useResendVerificationCode = ( }); const handleError = useErrorHandler(); - const sendVerificationCode = useApi(sendVerificationCodeApi); + const sendVerificationCode = useApi(resendVerificationCodeApi); + const { setVerificationId } = useContext(UserInteractionContext); const onResendVerificationCode = useCallback(async () => { - const payload = method === SignInIdentifier.Email ? { email: target } : { phone: target }; - const [error, result] = await sendVerificationCode(type, payload); + const [error, result] = await sendVerificationCode(flow, identifier); if (error) { await handleError(error); @@ -44,10 +42,12 @@ const useResendVerificationCode = ( } if (result) { + // Renew the verification ID in the context + setVerificationId(codeVerificationTypeMap[identifier.type], result.verificationId); setToast(t('description.passcode_sent')); restart(getTimeout(), true); } - }, [handleError, method, restart, sendVerificationCode, setToast, target, type]); + }, [flow, handleError, identifier, restart, sendVerificationCode, setToast, setVerificationId]); return { seconds, diff --git a/packages/experience/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts index 68f9987cf0e..a56687d711f 100644 --- a/packages/experience/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts @@ -1,13 +1,14 @@ -import type { EmailVerificationCodePayload, PhoneVerificationCodePayload } from '@logto/schemas'; -import { SignInIdentifier, SignInMode } from '@logto/schemas'; +import { + InteractionEvent, + SignInIdentifier, + SignInMode, + type VerificationCodeIdentifier, +} from '@logto/schemas'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { - registerWithVerifiedIdentifier, - signInWithVerificationCodeIdentifier, -} from '@/apis/interaction'; +import { identifyWithVerificationCode, registerWithVerifiedIdentifier } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import { useConfirmModal } from '@/hooks/use-confirm-modal'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; @@ -15,38 +16,42 @@ import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import { useSieMethods } from '@/hooks/use-sie'; -import type { VerificationCodeIdentifier } from '@/types'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; const useSignInFlowCodeVerification = ( - method: VerificationCodeIdentifier, - target: string, + identifier: VerificationCodeIdentifier, + verificationId: string, errorCallback?: () => void ) => { const { t } = useTranslation(); const { show } = useConfirmModal(); const navigate = useNavigate(); const redirectTo = useGlobalRedirectTo(); - const { signInMode } = useSieMethods(); - + const { signInMode, signUpMethods } = useSieMethods(); const handleError = useErrorHandler(); const registerWithIdentifierAsync = useApi(registerWithVerifiedIdentifier); - const asyncSignInWithVerificationCodeIdentifier = useApi(signInWithVerificationCodeIdentifier); + const asyncSignInWithVerificationCodeIdentifier = useApi(identifyWithVerificationCode); const { errorMessage, clearErrorMessage, generalVerificationCodeErrorHandlers } = useGeneralVerificationCodeErrorHandler(); const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); + const preRegisterErrorHandler = usePreSignInErrorHandler({ + interactionEvent: InteractionEvent.Register, + }); + const showIdentifierErrorAlert = useIdentifierErrorAlert(); const identifierNotExistErrorHandler = useCallback(async () => { + const { type, value } = identifier; + // Should not redirect user to register if is sign-in only mode or bind social flow - if (signInMode === SignInMode.SignIn) { - void showIdentifierErrorAlert(IdentifierErrorType.IdentifierNotExist, method, target); + if (signInMode === SignInMode.SignIn || !signUpMethods.includes(type)) { + void showIdentifierErrorAlert(IdentifierErrorType.IdentifierNotExist, type, value); return; } @@ -54,19 +59,15 @@ const useSignInFlowCodeVerification = ( show({ confirmText: 'action.create', ModalContent: t('description.sign_in_id_does_not_exist', { - type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`), + type: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`), value: - method === SignInIdentifier.Phone - ? formatPhoneNumberWithCountryCallingCode(target) - : target, + type === SignInIdentifier.Phone ? formatPhoneNumberWithCountryCallingCode(value) : value, }), onConfirm: async () => { - const [error, result] = await registerWithIdentifierAsync( - method === SignInIdentifier.Email ? { email: target } : { phone: target } - ); + const [error, result] = await registerWithIdentifierAsync(verificationId); if (error) { - await handleError(error, preSignInErrorHandler); + await handleError(error, preRegisterErrorHandler); return; } @@ -80,17 +81,18 @@ const useSignInFlowCodeVerification = ( }, }); }, [ + identifier, signInMode, + signUpMethods, show, t, - method, - target, - registerWithIdentifierAsync, showIdentifierErrorAlert, - navigate, + registerWithIdentifierAsync, + verificationId, handleError, - preSignInErrorHandler, + preRegisterErrorHandler, redirectTo, + navigate, ]); const errorHandlers = useMemo( @@ -109,12 +111,15 @@ const useSignInFlowCodeVerification = ( ); const onSubmit = useCallback( - async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => { - const [error, result] = await asyncSignInWithVerificationCodeIdentifier(payload); + async (code: string) => { + const [error, result] = await asyncSignInWithVerificationCodeIdentifier({ + verificationId, + identifier, + code, + }); if (error) { await handleError(error, errorHandlers); - return; } @@ -122,7 +127,14 @@ const useSignInFlowCodeVerification = ( await redirectTo(result.redirectTo); } }, - [asyncSignInWithVerificationCodeIdentifier, errorHandlers, handleError, redirectTo] + [ + asyncSignInWithVerificationCodeIdentifier, + errorHandlers, + handleError, + identifier, + redirectTo, + verificationId, + ] ); return { diff --git a/packages/experience/src/hooks/use-check-single-sign-on.ts b/packages/experience/src/hooks/use-check-single-sign-on.ts index 2f358c7ab27..67a9077d804 100644 --- a/packages/experience/src/hooks/use-check-single-sign-on.ts +++ b/packages/experience/src/hooks/use-check-single-sign-on.ts @@ -1,10 +1,10 @@ import { experience, type SsoConnectorMetadata } from '@logto/schemas'; -import { useCallback, useState, useContext } from 'react'; +import { useCallback, useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; -import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; +import { getSsoConnectors } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; @@ -13,7 +13,7 @@ import useSingleSignOn from './use-single-sign-on'; const useCheckSingleSignOn = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const request = useApi(getSingleSignOnConnectors); + const request = useApi(getSsoConnectors); const [errorMessage, setErrorMessage] = useState(); const { setSsoEmail, setSsoConnectors, availableSsoConnectorsMap } = useContext(UserInteractionContext); @@ -56,8 +56,8 @@ const useCheckSingleSignOn = () => { return; } - const connectors = result - ?.map((connectorId) => availableSsoConnectorsMap.get(connectorId)) + const connectors = result?.connectorIds + .map((connectorId) => availableSsoConnectorsMap.get(connectorId)) // eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific .filter((connector): connector is SsoConnectorMetadata => Boolean(connector)); diff --git a/packages/experience/src/hooks/use-mfa-error-handler.ts b/packages/experience/src/hooks/use-mfa-error-handler.ts index 129fd6a34f0..67bb715e35c 100644 --- a/packages/experience/src/hooks/use-mfa-error-handler.ts +++ b/packages/experience/src/hooks/use-mfa-error-handler.ts @@ -5,15 +5,11 @@ import { useNavigate } from 'react-router-dom'; import { validate } from 'superstruct'; import { UserMfaFlow } from '@/types'; -import { - type MfaFlowState, - mfaErrorDataGuard, - backupCodeErrorDataGuard, - type BackupCodeBindingState, -} from '@/types/guard'; +import { type MfaFlowState, mfaErrorDataGuard } from '@/types/guard'; import { isNativeWebview } from '@/utils/native-sdk'; import type { ErrorHandlers } from './use-error-handler'; +import useBackupCodeBinding from './use-start-backup-code-binding'; import useStartTotpBinding from './use-start-totp-binding'; import useStartWebAuthnProcessing from './use-start-webauthn-processing'; import useToast from './use-toast'; @@ -28,6 +24,7 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { const { setToast } = useToast(); const startTotpBinding = useStartTotpBinding({ replace }); const startWebAuthnProcessing = useStartWebAuthnProcessing({ replace }); + const startBackupCodeBinding = useBackupCodeBinding({ replace }); /** * Redirect the user to the corresponding MFA page. @@ -118,30 +115,13 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { [handleMfaRedirect, setToast] ); - const handleBackupCodeError = useCallback( - (error: RequestErrorBody) => { - const [_, data] = validate(error.data, backupCodeErrorDataGuard); - - if (!data) { - setToast(error.message); - return; - } - - navigate( - { pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.BackupCode}` }, - { replace, state: data satisfies BackupCodeBindingState } - ); - }, - [navigate, replace, setToast] - ); - const mfaVerificationErrorHandler = useMemo( () => ({ 'user.missing_mfa': handleMfaError(UserMfaFlow.MfaBinding), 'session.mfa.require_mfa_verification': handleMfaError(UserMfaFlow.MfaVerification), - 'session.mfa.backup_code_required': handleBackupCodeError, + 'session.mfa.backup_code_required': startBackupCodeBinding, }), - [handleBackupCodeError, handleMfaError] + [handleMfaError, startBackupCodeBinding] ); return mfaVerificationErrorHandler; diff --git a/packages/experience/src/hooks/use-password-action.ts b/packages/experience/src/hooks/use-password-action.ts deleted file mode 100644 index e6275d055d9..00000000000 --- a/packages/experience/src/hooks/use-password-action.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { type RequestErrorBody } from '@logto/schemas'; -import { useCallback } from 'react'; - -import useApi from '@/hooks/use-api'; - -import useErrorHandler, { type ErrorHandlers } from './use-error-handler'; -import usePasswordErrorMessage from './use-password-error-message'; -import { usePasswordPolicy } from './use-sie'; - -export type PasswordAction = (password: string) => Promise; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the args type, but `any` is needed for type inference -export type SuccessHandler = F extends (...args: any[]) => Promise - ? (result?: Response) => void - : never; - -type UsePasswordApiInit = { - api: PasswordAction; - setErrorMessage: (message?: string) => void; - errorHandlers: ErrorHandlers; - successHandler: SuccessHandler>; -}; - -const usePasswordAction = ({ - api, - errorHandlers, - setErrorMessage, - successHandler, -}: UsePasswordApiInit): [PasswordAction] => { - const asyncAction = useApi(api); - const handleError = useErrorHandler(); - const { getErrorMessage, getErrorMessageFromBody } = usePasswordErrorMessage(); - const { policyChecker } = usePasswordPolicy(); - const passwordRejectionHandler = useCallback( - (error: RequestErrorBody) => { - setErrorMessage(getErrorMessageFromBody(error)); - }, - [getErrorMessageFromBody, setErrorMessage] - ); - - const action = useCallback( - async (password: string) => { - // Perform fast check before sending request - const fastCheckErrorMessage = getErrorMessage(policyChecker.fastCheck(password)); - if (fastCheckErrorMessage) { - setErrorMessage(fastCheckErrorMessage); - return; - } - - const [error, result] = await asyncAction(password); - - if (error) { - await handleError(error, { - 'password.rejected': passwordRejectionHandler, - ...errorHandlers, - }); - - return; - } - - successHandler(result); - }, - [ - asyncAction, - errorHandlers, - getErrorMessage, - handleError, - passwordRejectionHandler, - policyChecker, - setErrorMessage, - successHandler, - ] - ); - - return [action]; -}; - -export default usePasswordAction; diff --git a/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts b/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts index 6795170b701..550945698fb 100644 --- a/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts +++ b/packages/experience/src/hooks/use-pre-sign-in-error-handler.ts @@ -10,8 +10,8 @@ import useRequiredProfileErrorHandler, { type Options = UseRequiredProfileErrorHandlerOptions & UseMfaVerificationErrorHandlerOptions; -const usePreSignInErrorHandler = ({ replace, linkSocial }: Options = {}): ErrorHandlers => { - const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, linkSocial }); +const usePreSignInErrorHandler = ({ replace, ...rest }: Options = {}): ErrorHandlers => { + const requiredProfileErrorHandler = useRequiredProfileErrorHandler({ replace, ...rest }); const mfaErrorHandler = useMfaErrorHandler({ replace }); return useMemo( diff --git a/packages/experience/src/hooks/use-required-profile-error-handler.ts b/packages/experience/src/hooks/use-required-profile-error-handler.ts index af74b4f0961..a0eed6c0ee9 100644 --- a/packages/experience/src/hooks/use-required-profile-error-handler.ts +++ b/packages/experience/src/hooks/use-required-profile-error-handler.ts @@ -1,9 +1,9 @@ -import { MissingProfile } from '@logto/schemas'; +import { InteractionEvent, MissingProfile } from '@logto/schemas'; import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { validate } from 'superstruct'; -import { UserFlow, SearchParameters } from '@/types'; +import { UserFlow, SearchParameters, type ContinueFlowInteractionEvent } from '@/types'; import { missingProfileErrorDataGuard } from '@/types/guard'; import { queryStringify } from '@/utils'; @@ -13,9 +13,19 @@ import useToast from './use-toast'; export type Options = { replace?: boolean; linkSocial?: string; + /** + * We use this param to track the current profile fulfillment flow. + * If is UserFlow.Register, we need to call the identify endpoint after the user completes the profile. + * If is UserFlow.SignIn, directly call the submitInteraction endpoint. + **/ + interactionEvent?: ContinueFlowInteractionEvent; }; -const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) => { +const useRequiredProfileErrorHandler = ({ + replace, + linkSocial, + interactionEvent = InteractionEvent.SignIn, +}: Options = {}) => { const navigate = useNavigate(); const { setToast } = useToast(); @@ -27,9 +37,6 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) = // Required as a sign up method but missing in the user profile const missingProfile = data?.missingProfile[0]; - // Required as a sign up method, verified email or phone can be found in Social Identity, but registered with a different account - const registeredSocialIdentity = data?.registeredSocialIdentity; - const linkSocialQueryString = linkSocial ? `?${queryStringify({ [SearchParameters.LinkSocial]: linkSocial })}` : undefined; @@ -41,7 +48,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) = { pathname: `/${UserFlow.Continue}/${missingProfile}`, }, - { replace } + { replace, state: { interactionEvent } } ); break; } @@ -53,7 +60,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) = pathname: `/${UserFlow.Continue}/${missingProfile}`, search: linkSocialQueryString, }, - { replace, state: { registeredSocialIdentity } } + { replace, state: { interactionEvent } } ); break; } @@ -65,7 +72,7 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial }: Options = {}) = } }, }), - [linkSocial, navigate, replace, setToast] + [interactionEvent, linkSocial, navigate, replace, setToast] ); return requiredProfileErrorHandler; diff --git a/packages/experience/src/hooks/use-send-mfa-payload.ts b/packages/experience/src/hooks/use-send-mfa-payload.ts index e407bb3231f..31e1366fb3b 100644 --- a/packages/experience/src/hooks/use-send-mfa-payload.ts +++ b/packages/experience/src/hooks/use-send-mfa-payload.ts @@ -1,7 +1,7 @@ import { type BindMfaPayload, type VerifyMfaPayload } from '@logto/schemas'; import { useCallback } from 'react'; -import { bindMfa, verifyMfa } from '@/apis/interaction'; +import { bindMfa, verifyMfa } from '@/apis/experience'; import { UserMfaFlow } from '@/types'; import useApi from './use-api'; @@ -13,17 +13,19 @@ export type SendMfaPayloadApiOptions = | { flow: UserMfaFlow.MfaBinding; payload: BindMfaPayload; + verificationId: string; } | { flow: UserMfaFlow.MfaVerification; payload: VerifyMfaPayload; + verificationId?: string; }; -const sendMfaPayloadApi = async ({ flow, payload }: SendMfaPayloadApiOptions) => { +const sendMfaPayloadApi = async ({ flow, payload, verificationId }: SendMfaPayloadApiOptions) => { if (flow === UserMfaFlow.MfaBinding) { - return bindMfa(payload); + return bindMfa(payload, verificationId); } - return verifyMfa(payload); + return verifyMfa(payload, verificationId); }; const useSendMfaPayload = () => { diff --git a/packages/experience/src/hooks/use-send-verification-code.ts b/packages/experience/src/hooks/use-send-verification-code.ts index 6fa7a791292..0269b2719bb 100644 --- a/packages/experience/src/hooks/use-send-verification-code.ts +++ b/packages/experience/src/hooks/use-send-verification-code.ts @@ -1,13 +1,20 @@ /* Replace legacy useSendVerificationCode hook with this one after the refactor */ import { SignInIdentifier } from '@logto/schemas'; -import { useState, useCallback } from 'react'; +import { conditional } from '@silverhand/essentials'; +import { useCallback, useContext, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import { sendVerificationCodeApi } from '@/apis/utils'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; -import { type VerificationCodeIdentifier, type UserFlow } from '@/types'; +import { + UserFlow, + type ContinueFlowInteractionEvent, + type VerificationCodeIdentifier, +} from '@/types'; +import { codeVerificationTypeMap } from '@/utils/sign-in-experience'; const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => { const [errorMessage, setErrorMessage] = useState(); @@ -15,6 +22,7 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) = const handleError = useErrorHandler(); const asyncSendVerificationCode = useApi(sendVerificationCodeApi); + const { setVerificationId } = useContext(UserInteractionContext); const clearErrorMessage = useCallback(() => { setErrorMessage(''); @@ -26,10 +34,15 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) = }; const onSubmit = useCallback( - async ({ identifier, value }: Payload) => { - const [error, result] = await asyncSendVerificationCode(flow, { - [identifier]: value, - }); + async ({ identifier, value }: Payload, interactionEvent?: ContinueFlowInteractionEvent) => { + const [error, result] = await asyncSendVerificationCode( + flow, + { + type: identifier, + value, + }, + interactionEvent + ); if (error) { await handleError(error, { @@ -44,6 +57,9 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) = } if (result) { + // Store the verification ID in the context so that we can use it in the next step + setVerificationId(codeVerificationTypeMap[identifier], result.verificationId); + navigate( { pathname: `/${flow}/verification-code`, @@ -51,11 +67,17 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) = }, { replace: replaceCurrentPage, + // Append the interaction event to the state so that we can use it in the next step + ...conditional( + flow === UserFlow.Continue && { + state: { interactionEvent }, + } + ), } ); } }, - [asyncSendVerificationCode, flow, handleError, navigate, replaceCurrentPage] + [asyncSendVerificationCode, flow, handleError, navigate, replaceCurrentPage, setVerificationId] ); return { diff --git a/packages/experience/src/hooks/use-session-storages.ts b/packages/experience/src/hooks/use-session-storages.ts index d3b79c6168c..09d1d8fb7f6 100644 --- a/packages/experience/src/hooks/use-session-storages.ts +++ b/packages/experience/src/hooks/use-session-storages.ts @@ -4,7 +4,11 @@ import { useCallback } from 'react'; import * as s from 'superstruct'; -import { identifierInputValueGuard, ssoConnectorMetadataGuard } from '@/types/guard'; +import { + identifierInputValueGuard, + ssoConnectorMetadataGuard, + verificationIdsMapGuard, +} from '@/types/guard'; const logtoStorageKeyPrefix = `logto:${window.location.origin}`; @@ -13,6 +17,7 @@ export enum StorageKeys { SsoConnectors = 'sso-connectors', IdentifierInputValue = 'identifier-input-value', ForgotPasswordIdentifierInputValue = 'forgot-password-identifier-input-value', + verificationIds = 'verification-ids', } const valueGuard = Object.freeze({ @@ -20,6 +25,7 @@ const valueGuard = Object.freeze({ [StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard), [StorageKeys.IdentifierInputValue]: identifierInputValueGuard, [StorageKeys.ForgotPasswordIdentifierInputValue]: identifierInputValueGuard, + [StorageKeys.verificationIds]: verificationIdsMapGuard, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details } satisfies { [key in StorageKeys]: s.Struct }); diff --git a/packages/experience/src/hooks/use-single-sign-on-watch.ts b/packages/experience/src/hooks/use-single-sign-on-watch.ts index e7794bc8103..a9c17f682f5 100644 --- a/packages/experience/src/hooks/use-single-sign-on-watch.ts +++ b/packages/experience/src/hooks/use-single-sign-on-watch.ts @@ -4,12 +4,12 @@ import { experience, type SsoConnectorMetadata, } from '@logto/schemas'; -import { useEffect, useCallback, useContext } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; -import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; +import { getSsoConnectors } from '@/apis/experience'; import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import useApi from '@/hooks/use-api'; import useSingleSignOn from '@/hooks/use-single-sign-on'; @@ -28,7 +28,7 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => { const { showSingleSignOnForm, setShowSingleSignOnForm } = useContext(SingleSignOnFormModeContext); - const request = useApi(getSingleSignOnConnectors, { silent: true }); + const request = useApi(getSsoConnectors, { silent: true }); const singleSignOn = useSingleSignOn(); @@ -43,7 +43,7 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => { return false; } - const connectors = result + const connectors = result.connectorIds .map((connectorId) => availableSsoConnectorsMap.get(connectorId)) // eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific .filter((connector): connector is SsoConnectorMetadata => Boolean(connector)); diff --git a/packages/experience/src/hooks/use-single-sign-on.ts b/packages/experience/src/hooks/use-single-sign-on.ts index a6b62109ca3..1ae2f3867e7 100644 --- a/packages/experience/src/hooks/use-single-sign-on.ts +++ b/packages/experience/src/hooks/use-single-sign-on.ts @@ -1,6 +1,8 @@ -import { useCallback } from 'react'; +import { VerificationType } from '@logto/schemas'; +import { useCallback, useContext } from 'react'; -import { getSingleSignOnUrl } from '@/apis/single-sign-on'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { getSsoAuthorizationUrl } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk'; @@ -10,11 +12,12 @@ import useGlobalRedirectTo from './use-global-redirect-to'; const useSingleSignOn = () => { const handleError = useErrorHandler(); - const asyncInvokeSingleSignOn = useApi(getSingleSignOnUrl); + const asyncInvokeSingleSignOn = useApi(getSsoAuthorizationUrl); const redirectTo = useGlobalRedirectTo({ shouldClearInteractionContextSession: false, isReplace: false, }); + const { setVerificationId } = useContext(UserInteractionContext); /** * Native IdP Sign In Flow @@ -45,11 +48,10 @@ const useSingleSignOn = () => { const state = generateState(); storeState(state, connectorId); - const [error, redirectUrl] = await asyncInvokeSingleSignOn( - connectorId, + const [error, result] = await asyncInvokeSingleSignOn(connectorId, { state, - `${window.location.origin}/callback/${connectorId}` - ); + redirectUri: `${window.location.origin}/callback/${connectorId}`, + }); if (error) { await handleError(error); @@ -57,19 +59,23 @@ const useSingleSignOn = () => { return; } - if (!redirectUrl) { + if (!result) { return; } + const { authorizationUri, verificationId } = result; + + setVerificationId(VerificationType.EnterpriseSso, verificationId); + // Invoke Native Sign In flow if (isNativeWebview()) { - nativeSignInHandler(redirectUrl, connectorId); + nativeSignInHandler(authorizationUri, connectorId); } // Invoke Web Sign In flow - await redirectTo(redirectUrl); + await redirectTo(authorizationUri); }, - [asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo] + [asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo, setVerificationId] ); }; diff --git a/packages/experience/src/hooks/use-skip-mfa.ts b/packages/experience/src/hooks/use-skip-mfa.ts index af480682b4a..5fb5caea950 100644 --- a/packages/experience/src/hooks/use-skip-mfa.ts +++ b/packages/experience/src/hooks/use-skip-mfa.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { skipMfa } from '@/apis/interaction'; +import { skipMfa } from '@/apis/experience'; import useApi from './use-api'; import useErrorHandler from './use-error-handler'; diff --git a/packages/experience/src/hooks/use-social-link-account.ts b/packages/experience/src/hooks/use-social-link-account.ts index 4e75ffbf153..ff378fe0840 100644 --- a/packages/experience/src/hooks/use-social-link-account.ts +++ b/packages/experience/src/hooks/use-social-link-account.ts @@ -1,22 +1,27 @@ import { useCallback } from 'react'; -import { linkWithSocial } from '@/apis/interaction'; +import { signInAndLinkWithSocial } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from './use-error-handler'; import useGlobalRedirectTo from './use-global-redirect-to'; +import usePreSignInErrorHandler from './use-pre-sign-in-error-handler'; const useLinkSocial = () => { const handleError = useErrorHandler(); - const asyncLinkWithSocial = useApi(linkWithSocial); + const asyncLinkWithSocial = useApi(signInAndLinkWithSocial); const redirectTo = useGlobalRedirectTo(); + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); return useCallback( - async (connectorId: string) => { - const [error, result] = await asyncLinkWithSocial(connectorId); + async (identifierVerificationId: string, socialVerificationId: string) => { + const [error, result] = await asyncLinkWithSocial( + identifierVerificationId, + socialVerificationId + ); if (error) { - await handleError(error); + await handleError(error, preSignInErrorHandler); return; } @@ -25,7 +30,7 @@ const useLinkSocial = () => { await redirectTo(result.redirectTo); } }, - [asyncLinkWithSocial, handleError, redirectTo] + [asyncLinkWithSocial, handleError, preSignInErrorHandler, redirectTo] ); }; diff --git a/packages/experience/src/hooks/use-social-register.ts b/packages/experience/src/hooks/use-social-register.ts index 60178007780..64fdb222001 100644 --- a/packages/experience/src/hooks/use-social-register.ts +++ b/packages/experience/src/hooks/use-social-register.ts @@ -1,25 +1,30 @@ +import { InteractionEvent } from '@logto/schemas'; import { useCallback } from 'react'; -import { registerWithVerifiedSocial } from '@/apis/interaction'; +import { registerWithVerifiedIdentifier } from '@/apis/experience'; import useApi from './use-api'; import useErrorHandler from './use-error-handler'; import useGlobalRedirectTo from './use-global-redirect-to'; import usePreSignInErrorHandler from './use-pre-sign-in-error-handler'; -const useSocialRegister = (connectorId?: string, replace?: boolean) => { +const useSocialRegister = (connectorId: string, replace?: boolean) => { const handleError = useErrorHandler(); - const asyncRegisterWithSocial = useApi(registerWithVerifiedSocial); + const asyncRegisterWithSocial = useApi(registerWithVerifiedIdentifier); const redirectTo = useGlobalRedirectTo(); - const preSignInErrorHandler = usePreSignInErrorHandler({ linkSocial: connectorId, replace }); + const preRegisterErrorHandler = usePreSignInErrorHandler({ + linkSocial: connectorId, + replace, + interactionEvent: InteractionEvent.Register, + }); return useCallback( - async (connectorId: string) => { - const [error, result] = await asyncRegisterWithSocial(connectorId); + async (verificationId: string) => { + const [error, result] = await asyncRegisterWithSocial(verificationId); if (error) { - await handleError(error, preSignInErrorHandler); + await handleError(error, preRegisterErrorHandler); return; } @@ -28,7 +33,7 @@ const useSocialRegister = (connectorId?: string, replace?: boolean) => { await redirectTo(result.redirectTo); } }, - [asyncRegisterWithSocial, handleError, preSignInErrorHandler, redirectTo] + [asyncRegisterWithSocial, handleError, preRegisterErrorHandler, redirectTo] ); }; diff --git a/packages/experience/src/hooks/use-start-backup-code-binding.ts b/packages/experience/src/hooks/use-start-backup-code-binding.ts new file mode 100644 index 00000000000..71005985098 --- /dev/null +++ b/packages/experience/src/hooks/use-start-backup-code-binding.ts @@ -0,0 +1,46 @@ +import { MfaFactor, VerificationType } from '@logto/schemas'; +import { useCallback, useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { createBackupCode } from '@/apis/experience'; +import { UserMfaFlow } from '@/types'; +import { type BackupCodeBindingState } from '@/types/guard'; + +import useApi from './use-api'; +import useErrorHandler from './use-error-handler'; + +type Options = { + replace?: boolean; +}; + +const useBackupCodeBinding = ({ replace }: Options = {}) => { + const navigate = useNavigate(); + const generateBackUpCodes = useApi(createBackupCode); + const { setVerificationId } = useContext(UserInteractionContext); + + const handleError = useErrorHandler(); + + return useCallback(async () => { + const [error, result] = await generateBackUpCodes(); + + if (error) { + await handleError(error); + return; + } + + if (!result) { + return; + } + + const { verificationId, codes } = result; + setVerificationId(VerificationType.BackupCode, verificationId); + + navigate( + { pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.BackupCode}` }, + { replace, state: { codes } satisfies BackupCodeBindingState } + ); + }, [generateBackUpCodes, handleError, navigate, replace, setVerificationId]); +}; + +export default useBackupCodeBinding; diff --git a/packages/experience/src/hooks/use-start-totp-binding.ts b/packages/experience/src/hooks/use-start-totp-binding.ts index 12347533b5c..b06087a1ef4 100644 --- a/packages/experience/src/hooks/use-start-totp-binding.ts +++ b/packages/experience/src/hooks/use-start-totp-binding.ts @@ -1,8 +1,9 @@ -import { MfaFactor } from '@logto/schemas'; -import { useCallback } from 'react'; +import { MfaFactor, VerificationType } from '@logto/schemas'; +import { useCallback, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; -import { createTotpSecret } from '@/apis/interaction'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { createTotpSecret } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import { UserMfaFlow } from '@/types'; @@ -15,6 +16,7 @@ type Options = { const useStartTotpBinding = ({ replace }: Options = {}) => { const navigate = useNavigate(); const asyncCreateTotpSecret = useApi(createTotpSecret); + const { setVerificationId } = useContext(UserInteractionContext); const handleError = useErrorHandler(); @@ -27,18 +29,20 @@ const useStartTotpBinding = ({ replace }: Options = {}) => { return; } - const { secret, secretQrCode } = result ?? {}; - - if (secret && secretQrCode) { + if (result) { + const { secret, secretQrCode, verificationId } = result; const state: TotpBindingState = { secret, secretQrCode, ...flowState, }; + + setVerificationId(VerificationType.TOTP, verificationId); + navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state }); } }, - [asyncCreateTotpSecret, handleError, navigate, replace] + [asyncCreateTotpSecret, handleError, navigate, replace, setVerificationId] ); }; diff --git a/packages/experience/src/hooks/use-start-webauthn-processing.ts b/packages/experience/src/hooks/use-start-webauthn-processing.ts index 124cf9e314f..d46d2bf9488 100644 --- a/packages/experience/src/hooks/use-start-webauthn-processing.ts +++ b/packages/experience/src/hooks/use-start-webauthn-processing.ts @@ -1,11 +1,9 @@ -import { MfaFactor } from '@logto/schemas'; -import { useCallback } from 'react'; +import { MfaFactor, VerificationType } from '@logto/schemas'; +import { useCallback, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; -import { - createWebAuthnRegistrationOptions, - generateWebAuthnAuthnOptions, -} from '@/apis/interaction'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { createWebAuthnRegistration, createWebAuthnAuthentication } from '@/apis/experience'; import { UserMfaFlow } from '@/types'; import { type WebAuthnState, type MfaFlowState } from '@/types/guard'; @@ -18,13 +16,14 @@ type Options = { const useStartWebAuthnProcessing = ({ replace }: Options = {}) => { const navigate = useNavigate(); - const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistrationOptions); - const asyncGenerateAuthnOptions = useApi(generateWebAuthnAuthnOptions); + const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistration); + const asyncGenerateAuthnOptions = useApi(createWebAuthnAuthentication); const handleError = useErrorHandler(); + const { setVerificationId } = useContext(UserInteractionContext); return useCallback( async (flow: UserMfaFlow, flowState: MfaFlowState) => { - const [error, options] = + const [error, result] = flow === UserMfaFlow.MfaBinding ? await asyncCreateRegistrationOptions() : await asyncGenerateAuthnOptions(); @@ -34,7 +33,10 @@ const useStartWebAuthnProcessing = ({ replace }: Options = {}) => { return; } - if (options) { + if (result) { + const { verificationId, options } = result; + setVerificationId(VerificationType.WebAuthn, verificationId); + const state: WebAuthnState = { options, ...flowState, @@ -43,7 +45,14 @@ const useStartWebAuthnProcessing = ({ replace }: Options = {}) => { navigate({ pathname: `/${flow}/${MfaFactor.WebAuthn}` }, { replace, state }); } }, - [asyncCreateRegistrationOptions, asyncGenerateAuthnOptions, handleError, navigate, replace] + [ + asyncCreateRegistrationOptions, + asyncGenerateAuthnOptions, + handleError, + navigate, + replace, + setVerificationId, + ] ); }; diff --git a/packages/experience/src/hooks/use-webauthn-operation.ts b/packages/experience/src/hooks/use-webauthn-operation.ts index ff88eb22c80..a809353b3ae 100644 --- a/packages/experience/src/hooks/use-webauthn-operation.ts +++ b/packages/experience/src/hooks/use-webauthn-operation.ts @@ -39,7 +39,7 @@ const useWebAuthnOperation = () => { * Therefore, we should avoid asynchronous operations before invoking the WebAuthn API or the os may consider the WebAuthn authorization is not initiated by the user. * So, we need to prepare the necessary WebAuthn options before calling the WebAuthn API, this is why we don't generate the options in this function. */ - async (options: WebAuthnOptions) => { + async (options: WebAuthnOptions, verificationId: string) => { if (!browserSupportsWebAuthn()) { setToast(t('mfa.webauthn_not_supported')); return; @@ -63,19 +63,26 @@ const useWebAuthnOperation = () => { } ); - if (response) { - /** - * Assert type manually to get the correct type - */ - void sendMfaPayload( - isAuthenticationResponseJSON(response) - ? { - flow: UserMfaFlow.MfaVerification, - payload: { ...response, type: MfaFactor.WebAuthn }, - } - : { flow: UserMfaFlow.MfaBinding, payload: { ...response, type: MfaFactor.WebAuthn } } - ); + if (!response) { + return; } + + /** + * Assert type manually to get the correct type + */ + void sendMfaPayload( + isAuthenticationResponseJSON(response) + ? { + flow: UserMfaFlow.MfaVerification, + payload: { ...response, type: MfaFactor.WebAuthn }, + verificationId, + } + : { + flow: UserMfaFlow.MfaBinding, + payload: { ...response, type: MfaFactor.WebAuthn }, + verificationId, + } + ); }, [sendMfaPayload, setToast, t] ); diff --git a/packages/experience/src/pages/Continue/SetEmailOrPhone/index.test.tsx b/packages/experience/src/pages/Continue/SetEmailOrPhone/index.test.tsx index 353c4080d2c..2065e31df6b 100644 --- a/packages/experience/src/pages/Continue/SetEmailOrPhone/index.test.tsx +++ b/packages/experience/src/pages/Continue/SetEmailOrPhone/index.test.tsx @@ -1,4 +1,4 @@ -import { MissingProfile, SignInIdentifier } from '@logto/schemas'; +import { InteractionEvent, MissingProfile, SignInIdentifier } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { fireEvent, waitFor } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; @@ -37,10 +37,17 @@ jest.mock('@/apis/utils', () => ({ })); describe('continue with email or phone', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + const renderPage = (missingProfile: VerificationCodeProfileType) => renderWithPageContext( - + ); @@ -75,7 +82,7 @@ describe('continue with email or phone', () => { ] satisfies Array<[VerificationCodeProfileType, VerificationCodeIdentifier, string]>)( 'should send verification code properly', async (type, identifier, input) => { - const { getByLabelText, getByText, container } = renderPage(type); + const { getByText, container } = renderPage(type); const inputField = container.querySelector('input[name="identifier"]'); const submitButton = getByText('action.continue'); @@ -92,9 +99,14 @@ describe('continue with email or phone', () => { }); await waitFor(() => { - expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Continue, { - [identifier]: identifier === SignInIdentifier.Phone ? `${countryCode}${input}` : input, - }); + expect(sendVerificationCodeApi).toBeCalledWith( + UserFlow.Continue, + { + type: identifier, + value: identifier === SignInIdentifier.Phone ? `${countryCode}${input}` : input, + }, + InteractionEvent.Register + ); }); } ); diff --git a/packages/experience/src/pages/Continue/SetEmailOrPhone/index.tsx b/packages/experience/src/pages/Continue/SetEmailOrPhone/index.tsx index 364919ccc6e..bcb4d3853a4 100644 --- a/packages/experience/src/pages/Continue/SetEmailOrPhone/index.tsx +++ b/packages/experience/src/pages/Continue/SetEmailOrPhone/index.tsx @@ -6,7 +6,7 @@ import { useContext } from 'react'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import useSendVerificationCode from '@/hooks/use-send-verification-code'; -import type { VerificationCodeIdentifier } from '@/types'; +import type { ContinueFlowInteractionEvent, VerificationCodeIdentifier } from '@/types'; import { UserFlow } from '@/types'; import IdentifierProfileForm from '../IdentifierProfileForm'; @@ -17,7 +17,7 @@ export type VerificationCodeProfileType = Exclude { +const SetEmailOrPhone = ({ missingProfile, interactionEvent }: Props) => { const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(UserFlow.Continue); const { setIdentifierInputValue } = useContext(UserInteractionContext); @@ -71,11 +71,11 @@ const SetEmailOrPhone = ({ missingProfile, notification }: Props) => { setIdentifierInputValue({ type: identifier, value }); - return onSubmit({ identifier, value }); + return onSubmit({ identifier, value }, interactionEvent); }; return ( - + ({ useNavigate: () => mockedNavigate, })); -jest.mock('@/apis/interaction', () => ({ - addProfile: jest.fn(async () => ({ redirectTo: '/' })), +jest.mock('@/apis/experience', () => ({ + updateProfile: jest.fn(async () => ({ redirectTo: '/' })), })); describe('SetPassword', () => { it('render set-password page properly without confirm password field', () => { const { queryByText, container } = renderWithPageContext( - + ); expect(container.querySelector('input[name="newPassword"]')).not.toBeNull(); @@ -41,7 +42,7 @@ describe('SetPassword', () => { }, }} > - + ); expect(container.querySelector('input[name="newPassword"]')).not.toBeNull(); @@ -60,7 +61,7 @@ describe('SetPassword', () => { }, }} > - + ); const submitButton = getByText('action.save_password'); @@ -95,7 +96,7 @@ describe('SetPassword', () => { }, }} > - + ); const submitButton = getByText('action.save_password'); @@ -115,7 +116,13 @@ describe('SetPassword', () => { }); await waitFor(() => { - expect(addProfile).toBeCalledWith({ password: '1234!@#$' }); + expect(updateProfile).toBeCalledWith( + { + type: 'password', + value: '1234!@#$', + }, + InteractionEvent.Register + ); }); }); }); diff --git a/packages/experience/src/pages/Continue/SetPassword/index.tsx b/packages/experience/src/pages/Continue/SetPassword/index.tsx index da72cd53d1d..5ca612aa4c8 100644 --- a/packages/experience/src/pages/Continue/SetPassword/index.tsx +++ b/packages/experience/src/pages/Continue/SetPassword/index.tsx @@ -2,17 +2,26 @@ import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; -import { addProfile } from '@/apis/interaction'; +import { updateProfile } from '@/apis/experience'; import SetPasswordForm from '@/containers/SetPassword'; +import useApi from '@/hooks/use-api'; import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; +import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; -import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action'; +import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker'; +import usePasswordRejectionErrorHandler from '@/hooks/use-password-rejection-handler'; import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import { usePasswordPolicy } from '@/hooks/use-sie'; +import { type ContinueFlowInteractionEvent } from '@/types'; -const SetPassword = () => { +type Props = { + readonly interactionEvent: ContinueFlowInteractionEvent; +}; + +const SetPassword = ({ interactionEvent }: Props) => { const [errorMessage, setErrorMessage] = useState(); + const clearErrorMessage = useCallback(() => { setErrorMessage(undefined); }, []); @@ -21,7 +30,12 @@ const SetPassword = () => { const { show } = usePromiseConfirmModal(); const redirectTo = useGlobalRedirectTo(); - const preSignInErrorHandler = usePreSignInErrorHandler(); + const checkPassword = usePasswordPolicyChecker({ setErrorMessage }); + const addPassword = useApi(updateProfile); + const handleError = useErrorHandler(); + + const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage }); + const preSignInErrorHandler = usePreSignInErrorHandler({ interactionEvent, replace: true }); const errorHandlers: ErrorHandlers = useMemo( () => ({ @@ -30,25 +44,36 @@ const SetPassword = () => { navigate(-1); }, ...preSignInErrorHandler, + ...passwordRejectionErrorHandler, }), - [navigate, preSignInErrorHandler, show] + [navigate, passwordRejectionErrorHandler, preSignInErrorHandler, show] ); - const successHandler: SuccessHandler = useCallback( - async (result) => { + + const onSubmitHandler = useCallback( + async (password: string) => { + const success = await checkPassword(password); + + if (!success) { + return; + } + + const [error, result] = await addPassword( + { type: 'password', value: password }, + interactionEvent + ); + + if (error) { + await handleError(error, errorHandlers); + return; + } + if (result?.redirectTo) { await redirectTo(result.redirectTo); } }, - [redirectTo] + [addPassword, checkPassword, errorHandlers, interactionEvent, handleError, redirectTo] ); - const [action] = usePasswordAction({ - api: async (password) => addProfile({ password }), - setErrorMessage, - errorHandlers, - successHandler, - }); - const { policy: { length: { min, max }, @@ -68,7 +93,7 @@ const SetPassword = () => { errorMessage={errorMessage} maxLength={max} clearErrorMessage={clearErrorMessage} - onSubmit={action} + onSubmit={onSubmitHandler} /> ); diff --git a/packages/experience/src/pages/Continue/SetUsername/index.test.tsx b/packages/experience/src/pages/Continue/SetUsername/index.test.tsx index 8bef67222d0..0572402b9cb 100644 --- a/packages/experience/src/pages/Continue/SetUsername/index.test.tsx +++ b/packages/experience/src/pages/Continue/SetUsername/index.test.tsx @@ -1,8 +1,9 @@ +import { InteractionEvent, SignInIdentifier } from '@logto/schemas'; import { act, waitFor, fireEvent } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; -import { addProfile } from '@/apis/interaction'; +import { updateProfile } from '@/apis/experience'; import SetUsername from '.'; @@ -19,15 +20,15 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockedNavigate, })); -jest.mock('@/apis/interaction', () => ({ - addProfile: jest.fn(async () => ({ redirectTo: '/' })), +jest.mock('@/apis/experience', () => ({ + updateProfile: jest.fn(async () => ({ redirectTo: '/' })), })); describe('SetUsername', () => { it('render SetUsername page properly', () => { const { queryByText, container } = renderWithPageContext( - + ); expect(container.querySelector('input[name="identifier"]')).not.toBeNull(); @@ -37,7 +38,7 @@ describe('SetUsername', () => { it('should submit properly', async () => { const { getByText, container } = renderWithPageContext( - + ); const submitButton = getByText('action.continue'); @@ -52,7 +53,10 @@ describe('SetUsername', () => { }); await waitFor(() => { - expect(addProfile).toBeCalledWith({ username: 'username' }); + expect(updateProfile).toBeCalledWith( + { type: SignInIdentifier.Username, value: 'username' }, + InteractionEvent.Register + ); }); }); }); diff --git a/packages/experience/src/pages/Continue/SetUsername/index.tsx b/packages/experience/src/pages/Continue/SetUsername/index.tsx index 6f506cb5a30..8d9cf96d4a7 100644 --- a/packages/experience/src/pages/Continue/SetUsername/index.tsx +++ b/packages/experience/src/pages/Continue/SetUsername/index.tsx @@ -1,20 +1,20 @@ import { SignInIdentifier } from '@logto/schemas'; -import type { TFuncKey } from 'i18next'; import { useContext } from 'react'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { type ContinueFlowInteractionEvent } from '@/types'; import IdentifierProfileForm from '../IdentifierProfileForm'; import useSetUsername from './use-set-username'; type Props = { - readonly notification?: TFuncKey; + readonly interactionEvent: ContinueFlowInteractionEvent; }; -const SetUsername = (props: Props) => { - const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername(); +const SetUsername = ({ interactionEvent }: Props) => { + const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername(interactionEvent); const { setIdentifierInputValue } = useContext(UserInteractionContext); @@ -32,7 +32,6 @@ const SetUsername = (props: Props) => { { +const useSetUsername = (interactionEvent: ContinueFlowInteractionEvent) => { const [errorMessage, setErrorMessage] = useState(); const clearErrorMessage = useCallback(() => { setErrorMessage(''); }, []); - const asyncAddProfile = useApi(addProfile); + const asyncAddProfile = useApi(updateProfile); const handleError = useErrorHandler(); const redirectTo = useGlobalRedirectTo(); - const preSignInErrorHandler = usePreSignInErrorHandler(); + const preSignInErrorHandler = usePreSignInErrorHandler({ + interactionEvent, + }); const errorHandlers: ErrorHandlers = useMemo( () => ({ @@ -32,7 +36,10 @@ const useSetUsername = () => { const onSubmit = useCallback( async (username: string) => { - const [error, result] = await asyncAddProfile({ username }); + const [error, result] = await asyncAddProfile( + { type: SignInIdentifier.Username, value: username }, + interactionEvent + ); if (error) { await handleError(error, errorHandlers); @@ -44,7 +51,7 @@ const useSetUsername = () => { await redirectTo(result.redirectTo); } }, - [asyncAddProfile, errorHandlers, handleError, redirectTo] + [asyncAddProfile, errorHandlers, handleError, interactionEvent, redirectTo] ); return { errorMessage, clearErrorMessage, onSubmit }; diff --git a/packages/experience/src/pages/Continue/index.tsx b/packages/experience/src/pages/Continue/index.tsx index 1e747b8e190..2fa40c21de6 100644 --- a/packages/experience/src/pages/Continue/index.tsx +++ b/packages/experience/src/pages/Continue/index.tsx @@ -1,7 +1,9 @@ import { MissingProfile } from '@logto/schemas'; -import { useParams } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; +import { validate } from 'superstruct'; import ErrorPage from '@/pages/ErrorPage'; +import { continueFlowStateGuard } from '@/types/guard'; import SetEmailOrPhone from './SetEmailOrPhone'; import SetPassword from './SetPassword'; @@ -13,13 +15,22 @@ type Parameters = { const Continue = () => { const { method = '' } = useParams(); + const { state } = useLocation(); + + const [, continueFlowState] = validate(state, continueFlowStateGuard); + + if (!continueFlowState) { + return ; + } + + const { interactionEvent } = continueFlowState; if (method === MissingProfile.password) { - return ; + return ; } if (method === MissingProfile.username) { - return ; + return ; } if ( @@ -27,7 +38,7 @@ const Continue = () => { method === MissingProfile.phone || method === MissingProfile.emailOrPhone ) { - return ; + return ; } return ; diff --git a/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.test.tsx b/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.test.tsx index 95229af507a..275cb84435b 100644 --- a/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.test.tsx +++ b/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.test.tsx @@ -1,10 +1,10 @@ -import { InteractionEvent, SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { act, fireEvent, waitFor } from '@testing-library/react'; import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; -import { putInteraction, sendVerificationCode } from '@/apis/interaction'; +import { sendVerificationCodeApi } from '@/apis/utils'; import { UserFlow, type VerificationCodeIdentifier } from '@/types'; import ForgotPasswordForm from '.'; @@ -21,9 +21,8 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockedNavigate, })); -jest.mock('@/apis/interaction', () => ({ - sendVerificationCode: jest.fn(() => ({ success: true })), - putInteraction: jest.fn(() => ({ success: true })), +jest.mock('@/apis/utils', () => ({ + sendVerificationCodeApi: jest.fn().mockResolvedValue({ verificationId: '123' }), })); describe('ForgotPasswordForm', () => { @@ -47,6 +46,8 @@ describe('ForgotPasswordForm', () => { Object.defineProperty(window, 'location', { value: originalLocation, }); + + jest.clearAllMocks(); }); describe.each([ @@ -84,8 +85,14 @@ describe('ForgotPasswordForm', () => { }); await waitFor(() => { - expect(putInteraction).toBeCalledWith(InteractionEvent.ForgotPassword); - expect(sendVerificationCode).toBeCalledWith({ email }); + expect(sendVerificationCodeApi).toBeCalledWith( + UserFlow.ForgotPassword, + { + type: identifier, + value, + }, + undefined + ); expect(mockedNavigate).toBeCalledWith( { pathname: `/${UserFlow.ForgotPassword}/verification-code`, diff --git a/packages/experience/src/pages/MfaBinding/BackupCodeBinding/index.tsx b/packages/experience/src/pages/MfaBinding/BackupCodeBinding/index.tsx index 1cf8cda40b5..2c49bad77ef 100644 --- a/packages/experience/src/pages/MfaBinding/BackupCodeBinding/index.tsx +++ b/packages/experience/src/pages/MfaBinding/BackupCodeBinding/index.tsx @@ -1,10 +1,11 @@ import { MfaFactor } from '@logto/schemas'; import { t } from 'i18next'; -import { useState } from 'react'; +import { useContext, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { validate } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import Button from '@/components/Button'; import DynamicT from '@/components/DynamicT'; import useSendMfaPayload from '@/hooks/use-send-mfa-payload'; @@ -20,11 +21,13 @@ const BackupCodeBinding = () => { const { copyText, downloadText } = useTextHandler(); const sendMfaPayload = useSendMfaPayload(); const [isSubmitting, setIsSubmitting] = useState(false); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[MfaFactor.BackupCode]; const { state } = useLocation(); const [, backupCodeBindingState] = validate(state, backupCodeBindingStateGuard); - if (!backupCodeBindingState) { + if (!backupCodeBindingState || !verificationId) { return ; } @@ -72,6 +75,7 @@ const BackupCodeBinding = () => { await sendMfaPayload({ flow: UserMfaFlow.MfaBinding, payload: { type: MfaFactor.BackupCode }, + verificationId, }); setIsSubmitting(false); }} diff --git a/packages/experience/src/pages/MfaBinding/TotpBinding/VerificationSection.tsx b/packages/experience/src/pages/MfaBinding/TotpBinding/VerificationSection.tsx index 3f46d54e0e7..a4bded77c3f 100644 --- a/packages/experience/src/pages/MfaBinding/TotpBinding/VerificationSection.tsx +++ b/packages/experience/src/pages/MfaBinding/TotpBinding/VerificationSection.tsx @@ -4,7 +4,11 @@ import SectionLayout from '@/Layout/SectionLayout'; import TotpCodeVerification from '@/containers/TotpCodeVerification'; import { UserMfaFlow } from '@/types'; -const VerificationSection = () => { +type Props = { + readonly verificationId: string; +}; + +const VerificationSection = ({ verificationId }: Props) => { const { t } = useTranslation(); return ( @@ -16,7 +20,7 @@ const VerificationSection = () => { }} description="mfa.enter_one_time_code_link_description" > - + ); }; diff --git a/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx b/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx index 7d4bd534b4e..45705aa66c0 100644 --- a/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx +++ b/packages/experience/src/pages/MfaBinding/TotpBinding/index.tsx @@ -1,8 +1,11 @@ +import { VerificationType } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; +import { useContext } from 'react'; import { useLocation } from 'react-router-dom'; import { validate } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import Divider from '@/components/Divider'; import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink'; import useSkipMfa from '@/hooks/use-skip-mfa'; @@ -17,9 +20,12 @@ import styles from './index.module.scss'; const TotpBinding = () => { const { state } = useLocation(); const [, totpBindingState] = validate(state, totpBindingStateGuard); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.TOTP]; + const skipMfa = useSkipMfa(); - if (!totpBindingState) { + if (!totpBindingState || !verificationId) { return ; } @@ -33,7 +39,7 @@ const TotpBinding = () => {
- + {availableFactors.length > 1 && ( <> diff --git a/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx b/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx index 610fb5c4ef3..5c7106fc5c5 100644 --- a/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx +++ b/packages/experience/src/pages/MfaBinding/WebAuthnBinding/index.tsx @@ -1,9 +1,11 @@ +import { VerificationType } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; -import { useState } from 'react'; +import { useContext, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { validate } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import Button from '@/components/Button'; import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink'; import useSkipMfa from '@/hooks/use-skip-mfa'; @@ -18,11 +20,14 @@ import styles from './index.module.scss'; const WebAuthnBinding = () => { const { state } = useLocation(); const [, webAuthnState] = validate(state, webAuthnStateGuard); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.WebAuthn]; + const handleWebAuthn = useWebAuthnOperation(); const skipMfa = useSkipMfa(); const [isCreatingPasskey, setIsCreatingPasskey] = useState(false); - if (!webAuthnState) { + if (!webAuthnState || !verificationId) { return ; } @@ -43,7 +48,7 @@ const WebAuthnBinding = () => { isLoading={isCreatingPasskey} onClick={async () => { setIsCreatingPasskey(true); - await handleWebAuthn(options); + await handleWebAuthn(options, verificationId); setIsCreatingPasskey(false); }} /> diff --git a/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx b/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx index e825f06847a..82db65e42c2 100644 --- a/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx +++ b/packages/experience/src/pages/MfaVerification/WebAuthnVerification/index.tsx @@ -1,9 +1,11 @@ -import { useState } from 'react'; +import { VerificationType } from '@logto/schemas'; +import { useContext, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { validate } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SectionLayout from '@/Layout/SectionLayout'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import Button from '@/components/Button'; import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink'; import useWebAuthnOperation from '@/hooks/use-webauthn-operation'; @@ -17,10 +19,13 @@ import styles from './index.module.scss'; const WebAuthnVerification = () => { const { state } = useLocation(); const [, webAuthnState] = validate(state, webAuthnStateGuard); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.WebAuthn]; + const handleWebAuthn = useWebAuthnOperation(); const [isVerifying, setIsVerifying] = useState(false); - if (!webAuthnState) { + if (!webAuthnState || !verificationId) { return ; } @@ -42,7 +47,7 @@ const WebAuthnVerification = () => { isLoading={isVerifying} onClick={async () => { setIsVerifying(true); - await handleWebAuthn(options); + await handleWebAuthn(options, verificationId); setIsVerifying(false); }} /> diff --git a/packages/experience/src/pages/RegisterPassword/index.test.tsx b/packages/experience/src/pages/RegisterPassword/index.test.tsx index 81aef9f3c27..cbe889b3804 100644 --- a/packages/experience/src/pages/RegisterPassword/index.test.tsx +++ b/packages/experience/src/pages/RegisterPassword/index.test.tsx @@ -5,7 +5,7 @@ import { useLocation } from 'react-router-dom'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings } from '@/__mocks__/logto'; -import { setUserPassword } from '@/apis/interaction'; +import { continueRegisterWithPassword } from '@/apis/experience'; import RegisterPassword from '.'; @@ -17,8 +17,8 @@ jest.mock('react-router-dom', () => ({ useLocation: jest.fn(() => ({ state: { username: 'username' } })), })); -jest.mock('@/apis/interaction', () => ({ - setUserPassword: jest.fn(async () => ({ redirectTo: '/' })), +jest.mock('@/apis/experience', () => ({ + continueRegisterWithPassword: jest.fn(async () => ({ redirectTo: '/' })), })); const useLocationMock = useLocation as jest.Mock; @@ -148,7 +148,7 @@ describe('', () => { }); await waitFor(() => { - expect(setUserPassword).toBeCalledWith('1234asdf'); + expect(continueRegisterWithPassword).toBeCalledWith('1234asdf'); }); }); }); diff --git a/packages/experience/src/pages/ResetPassword/index.test.tsx b/packages/experience/src/pages/ResetPassword/index.test.tsx index 24fd89a38a4..5d57db5bb5a 100644 --- a/packages/experience/src/pages/ResetPassword/index.test.tsx +++ b/packages/experience/src/pages/ResetPassword/index.test.tsx @@ -2,7 +2,7 @@ import { act, waitFor, fireEvent } from '@testing-library/react'; import { Routes, Route } from 'react-router-dom'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; -import { setUserPassword } from '@/apis/interaction'; +import { resetPassword } from '@/apis/experience'; import ResetPassword from '.'; @@ -13,8 +13,8 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockedNavigate, })); -jest.mock('@/apis/interaction', () => ({ - setUserPassword: jest.fn(async () => ({ redirectTo: '/' })), +jest.mock('@/apis/experience', () => ({ + resetPassword: jest.fn(async () => ({ redirectTo: '/' })), })); describe('ForgotPassword', () => { @@ -73,7 +73,7 @@ describe('ForgotPassword', () => { }); await waitFor(() => { - expect(setUserPassword).toBeCalledWith('1234!@#$'); + expect(resetPassword).toBeCalledWith('1234!@#$'); }); }); }); diff --git a/packages/experience/src/pages/ResetPassword/index.tsx b/packages/experience/src/pages/ResetPassword/index.tsx index 698badacc79..eddf44d8017 100644 --- a/packages/experience/src/pages/ResetPassword/index.tsx +++ b/packages/experience/src/pages/ResetPassword/index.tsx @@ -4,11 +4,13 @@ import { useNavigate } from 'react-router-dom'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; -import { setUserPassword } from '@/apis/interaction'; +import { resetPassword } from '@/apis/experience'; import SetPassword from '@/containers/SetPassword'; +import useApi from '@/hooks/use-api'; import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; -import { type ErrorHandlers } from '@/hooks/use-error-handler'; -import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action'; +import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler'; +import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker'; +import usePasswordRejectionErrorHandler from '@/hooks/use-password-rejection-handler'; import { usePasswordPolicy } from '@/hooks/use-sie'; import useToast from '@/hooks/use-toast'; @@ -22,6 +24,13 @@ const ResetPassword = () => { const navigate = useNavigate(); const { show } = usePromiseConfirmModal(); const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext); + + const checkPassword = usePasswordPolicyChecker({ setErrorMessage }); + const asyncResetPassword = useApi(resetPassword); + const handleError = useErrorHandler(); + + const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage }); + const errorHandlers: ErrorHandlers = useMemo( () => ({ 'session.verification_session_not_found': async (error) => { @@ -31,29 +40,43 @@ const ResetPassword = () => { 'user.same_password': (error) => { setErrorMessage(error.message); }, + ...passwordRejectionErrorHandler, }), - [navigate, setErrorMessage, show] + [navigate, passwordRejectionErrorHandler, show] ); - const successHandler: SuccessHandler = useCallback( - (result) => { - if (result) { - // Clear the forgot password identifier input value after the password is set - setForgotPasswordIdentifierInputValue(undefined); - setToast(t('description.password_changed')); - navigate('/sign-in', { replace: true }); + const onSubmitHandler = useCallback( + async (password: string) => { + const success = await checkPassword(password); + + if (!success) { + return; } + + const [error] = await asyncResetPassword(password); + + if (error) { + await handleError(error, errorHandlers); + return; + } + + // Clear the forgot password identifier input value + setForgotPasswordIdentifierInputValue(undefined); + setToast(t('description.password_changed')); + navigate('/sign-in', { replace: true }); }, - [navigate, setForgotPasswordIdentifierInputValue, setToast, t] + [ + asyncResetPassword, + checkPassword, + errorHandlers, + handleError, + navigate, + setForgotPasswordIdentifierInputValue, + setToast, + t, + ] ); - const [action] = usePasswordAction({ - api: setUserPassword, - setErrorMessage, - errorHandlers, - successHandler, - }); - const { policy: { length: { min, max }, @@ -73,7 +96,7 @@ const ResetPassword = () => { errorMessage={errorMessage} maxLength={max} clearErrorMessage={clearErrorMessage} - onSubmit={action} + onSubmit={onSubmitHandler} /> ); diff --git a/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx b/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx index cf255021db9..159e9cc8798 100644 --- a/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx +++ b/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx @@ -4,17 +4,17 @@ import { fireEvent, waitFor, act } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import { signInWithPasswordIdentifier, - putInteraction, + initInteraction, sendVerificationCode, -} from '@/apis/interaction'; +} from '@/apis/experience'; import { UserFlow } from '@/types'; import PasswordForm from '.'; -jest.mock('@/apis/interaction', () => ({ +jest.mock('@/apis/experience', () => ({ signInWithPasswordIdentifier: jest.fn(() => ({ redirectTo: '/' })), sendVerificationCode: jest.fn(() => ({ success: true })), - putInteraction: jest.fn(() => ({ success: true })), + initInteraction: jest.fn(() => ({ success: true })), })); const mockedNavigate = jest.fn(); @@ -90,8 +90,11 @@ describe('PasswordSignInForm', () => { }); await waitFor(() => { - expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn); - expect(sendVerificationCode).toBeCalledWith({ [identifier]: value }); + expect(initInteraction).toBeCalledWith(InteractionEvent.SignIn); + expect(sendVerificationCode).toBeCalledWith(InteractionEvent.SignIn, { + type: identifier, + value, + }); }); expect(mockedNavigate).toBeCalledWith( diff --git a/packages/experience/src/pages/SignInPassword/index.test.tsx b/packages/experience/src/pages/SignInPassword/index.test.tsx index e8ab0511f8b..e330cda41a0 100644 --- a/packages/experience/src/pages/SignInPassword/index.test.tsx +++ b/packages/experience/src/pages/SignInPassword/index.test.tsx @@ -1,6 +1,5 @@ import { SignInIdentifier } from '@logto/schemas'; import { renderHook } from '@testing-library/react'; -import { useLocation } from 'react-router-dom'; import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; @@ -13,7 +12,6 @@ import SignInPassword from '.'; describe('SignInPassword', () => { const { result } = renderHook(() => useSessionStorage()); const { set, remove } = result.current; - const mockUseLocation = useLocation as jest.Mock; const email = 'email@logto.io'; const phone = '18571111111'; const username = 'foo'; diff --git a/packages/experience/src/pages/SocialLinkAccount/index.test.tsx b/packages/experience/src/pages/SocialLinkAccount/index.test.tsx index 3a0d1015de2..0bad1d20898 100644 --- a/packages/experience/src/pages/SocialLinkAccount/index.test.tsx +++ b/packages/experience/src/pages/SocialLinkAccount/index.test.tsx @@ -1,9 +1,12 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, VerificationType } from '@logto/schemas'; +import { renderHook } from '@testing-library/react'; import { Route, Routes } from 'react-router-dom'; +import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings } from '@/__mocks__/logto'; +import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; import SocialRegister from '.'; @@ -14,13 +17,24 @@ jest.mock('react-router-dom', () => ({ })), })); +const verificationIdsMap = { [VerificationType.Social]: 'foo' }; + describe('SocialRegister', () => { + const { result } = renderHook(() => useSessionStorage()); + const { set } = result.current; + + beforeAll(() => { + set(StorageKeys.verificationIds, verificationIdsMap); + }); + it('render', () => { const { queryByText } = renderWithPageContext( - - } /> - + + + } /> + + , { initialEntries: ['/social/link/github'] } ); @@ -40,9 +54,11 @@ describe('SocialRegister', () => { }, }} > - - } /> - + + + } /> + + , { initialEntries: ['/social/link/github'] } ); @@ -62,9 +78,11 @@ describe('SocialRegister', () => { }, }} > - - } /> - + + + } /> + + , { initialEntries: ['/social/link/github'] } ); @@ -84,9 +102,11 @@ describe('SocialRegister', () => { }, }} > - - } /> - + + + } /> + + , { initialEntries: ['/social/link/github'] } ); diff --git a/packages/experience/src/pages/SocialLinkAccount/index.tsx b/packages/experience/src/pages/SocialLinkAccount/index.tsx index 15f094c2b86..ac60569813b 100644 --- a/packages/experience/src/pages/SocialLinkAccount/index.tsx +++ b/packages/experience/src/pages/SocialLinkAccount/index.tsx @@ -1,9 +1,11 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, VerificationType } from '@logto/schemas'; import type { TFuncKey } from 'i18next'; -import { useParams, useLocation } from 'react-router-dom'; +import { useContext } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { is } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import SocialLinkAccountContainer from '@/containers/SocialLinkAccount'; import { useSieMethods } from '@/hooks/use-sie'; import ErrorPage from '@/pages/ErrorPage'; @@ -36,6 +38,8 @@ const SocialLinkAccount = () => { const { connectorId } = useParams(); const { state } = useLocation(); const { signUpMethods } = useSieMethods(); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.Social]; if (!is(state, socialAccountNotExistErrorDataGuard)) { return ; @@ -45,11 +49,19 @@ const SocialLinkAccount = () => { return ; } + if (!verificationId) { + return ; + } + const { relatedUser } = state; return ( - + ); }; diff --git a/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx b/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx index 8009817c281..485dd0603b7 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx +++ b/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx @@ -1,12 +1,14 @@ -import { waitFor } from '@testing-library/react'; +import { VerificationType } from '@logto/schemas'; +import { renderHook, waitFor } from '@testing-library/react'; import { Navigate, Route, Routes, useSearchParams } from 'react-router-dom'; +import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; -import { mockSsoConnectors, mockSignInExperienceSettings } from '@/__mocks__/logto'; +import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { socialConnectors } from '@/__mocks__/social-connectors'; -import { signInWithSocial } from '@/apis/interaction'; -import { singleSignOnAuthorization } from '@/apis/single-sign-on'; +import { verifySocialVerification, signInWithSso } from '@/apis/experience'; +import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; import { type SignInExperienceResponse } from '@/types'; import { generateState, storeState } from '@/utils/social-connectors'; @@ -17,12 +19,10 @@ jest.mock('i18next', () => ({ language: 'en', })); -jest.mock('@/apis/interaction', () => ({ - signInWithSocial: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), -})); - -jest.mock('@/apis/single-sign-on', () => ({ - singleSignOnAuthorization: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), +jest.mock('@/apis/experience', () => ({ + verifySocialVerification: jest.fn().mockResolvedValue({ verificationId: 'foo' }), + identifyAndSubmitInteraction: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), + signInWithSso: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), })); jest.mock('react-router-dom', () => ({ @@ -34,7 +34,19 @@ jest.mock('react-router-dom', () => ({ const mockUseSearchParameters = useSearchParams as jest.Mock; const mockNavigate = Navigate as jest.Mock; +const verificationIdsMap = { + [VerificationType.Social]: 'foo', + [VerificationType.EnterpriseSso]: 'bar', +}; + describe('SocialCallbackPage with code', () => { + const { result } = renderHook(() => useSessionStorage()); + const { set } = result.current; + + beforeAll(() => { + set(StorageKeys.verificationIds, verificationIdsMap); + }); + describe('fallback', () => { it('should redirect to /sign-in if connectorId is not found', async () => { mockUseSearchParameters.mockReturnValue([new URLSearchParams('code=foo'), jest.fn()]); @@ -49,7 +61,7 @@ describe('SocialCallbackPage with code', () => { ); await waitFor(() => { - expect(signInWithSocial).not.toBeCalled(); + expect(verifySocialVerification).not.toBeCalled(); expect(mockNavigate.mock.calls[0][0].to).toBe('/sign-in'); }); }); @@ -68,20 +80,22 @@ describe('SocialCallbackPage with code', () => { renderWithPageContext( - - } /> - + + + } /> + + , { initialEntries: [`/callback/social/${connectorId}`] } ); await waitFor(() => { - expect(signInWithSocial).toBeCalled(); + expect(verifySocialVerification).toBeCalled(); }); }); it('callback with invalid state should not call signInWithSocial', async () => { - (signInWithSocial as jest.Mock).mockClear(); + (verifySocialVerification as jest.Mock).mockClear(); mockUseSearchParameters.mockReturnValue([ new URLSearchParams(`state=bar&code=foo`), @@ -90,15 +104,17 @@ describe('SocialCallbackPage with code', () => { renderWithPageContext( - - } /> - + + + } /> + + , { initialEntries: [`/callback/social/${connectorId}`] } ); await waitFor(() => { - expect(signInWithSocial).not.toBeCalled(); + expect(verifySocialVerification).not.toBeCalled(); }); }); }); @@ -121,20 +137,22 @@ describe('SocialCallbackPage with code', () => { renderWithPageContext( - - } /> - + + + } /> + + , { initialEntries: [`/callback/social/${connectorId}`] } ); await waitFor(() => { - expect(singleSignOnAuthorization).toBeCalled(); + expect(signInWithSso).toBeCalled(); }); }); - it('callback with invalid state should not call singleSignOnAuthorization', async () => { - (singleSignOnAuthorization as jest.Mock).mockClear(); + it('callback with invalid state should not call signInWithSso', async () => { + (signInWithSso as jest.Mock).mockClear(); mockUseSearchParameters.mockReturnValue([ new URLSearchParams(`state=bar&code=foo`), @@ -143,15 +161,17 @@ describe('SocialCallbackPage with code', () => { renderWithPageContext( - - } /> - + + + } /> + + , { initialEntries: [`/callback/social/${connectorId}`] } ); await waitFor(() => { - expect(singleSignOnAuthorization).not.toBeCalled(); + expect(signInWithSso).not.toBeCalled(); }); }); }); diff --git a/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts index bfc0b8da19e..846acfc1992 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts +++ b/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts @@ -1,9 +1,10 @@ -import { AgreeToTermsPolicy, SignInMode, experience } from '@logto/schemas'; -import { useCallback, useEffect, useState } from 'react'; +import { AgreeToTermsPolicy, SignInMode, VerificationType, experience } from '@logto/schemas'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { singleSignOnAuthorization, singleSignOnRegistration } from '@/apis/single-sign-on'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { registerWithVerifiedIdentifier, signInWithSso } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; @@ -15,13 +16,13 @@ import { validateState } from '@/utils/social-connectors'; const useSingleSignOnRegister = () => { const handleError = useErrorHandler(); - const request = useApi(singleSignOnRegistration); + const request = useApi(registerWithVerifiedIdentifier); const { termsValidation, agreeToTermsPolicy } = useTerms(); const navigate = useNavigate(); const redirectTo = useGlobalRedirectTo(); return useCallback( - async (connectorId: string) => { + async (verificationId: string) => { /** * Agree to terms and conditions first before proceeding * If the agreement policy is `Manual`, the user must agree to the terms to reach this step. @@ -32,7 +33,7 @@ const useSingleSignOnRegister = () => { return; } - const [error, result] = await request(connectorId); + const [error, result] = await request(verificationId); if (error) { await handleError(error); @@ -66,19 +67,24 @@ const useSingleSignOnListener = (connectorId: string) => { const { setToast } = useToast(); const redirectTo = useGlobalRedirectTo(); const { signInMode } = useSieMethods(); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.EnterpriseSso]; const handleError = useErrorHandler(); const navigate = useNavigate(); - const singleSignOnAuthorizationRequest = useApi(singleSignOnAuthorization); + const singleSignOnAuthorizationRequest = useApi(signInWithSso); const registerSingleSignOnIdentity = useSingleSignOnRegister(); const singleSignOnHandler = useCallback( - async (connectorId: string, data: Record) => { + async (connectorId: string, verificationId: string, data: Record) => { const [error, result] = await singleSignOnAuthorizationRequest(connectorId, { - ...data, - // For connector validation use - redirectUri: `${window.location.origin}/callback/${connectorId}`, + verificationId, + connectorData: { + ...data, + // For connector validation use + redirectUri: `${window.location.origin}/callback/${connectorId}`, + }, }); if (error) { @@ -92,7 +98,7 @@ const useSingleSignOnListener = (connectorId: string) => { return; } - await registerSingleSignOnIdentity(connectorId); + await registerSingleSignOnIdentity(verificationId); }, // Redirect to sign-in page if error is not handled by the error handlers global: async (error) => { @@ -138,7 +144,14 @@ const useSingleSignOnListener = (connectorId: string) => { return; } - void singleSignOnHandler(connectorId, rest); + // Validate the verificationId + if (!verificationId) { + setToast(t('error.invalid_session')); + navigate('/' + experience.routes.signIn); + return; + } + + void singleSignOnHandler(connectorId, verificationId, rest); }, [ connectorId, isConsumed, @@ -148,6 +161,7 @@ const useSingleSignOnListener = (connectorId: string) => { setToast, singleSignOnHandler, t, + verificationId, ]); return { loading }; diff --git a/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts index 6f1c17b0596..929714fa3fa 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts +++ b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts @@ -1,12 +1,23 @@ import { GoogleConnector } from '@logto/connector-kit'; import type { RequestErrorBody } from '@logto/schemas'; -import { AgreeToTermsPolicy, InteractionEvent, SignInMode, experience } from '@logto/schemas'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + AgreeToTermsPolicy, + InteractionEvent, + SignInMode, + VerificationType, + experience, +} from '@logto/schemas'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { validate } from 'superstruct'; -import { putInteraction, signInWithSocial } from '@/apis/interaction'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { + identifyAndSubmitInteraction, + initInteraction, + verifySocialVerification, +} from '@/apis/experience'; import useBindSocialRelatedUser from '@/containers/SocialLinkAccount/use-social-link-related-user'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; @@ -28,26 +39,37 @@ const useSocialSignInListener = (connectorId: string) => { const { termsValidation, agreeToTermsPolicy } = useTerms(); const [isConsumed, setIsConsumed] = useState(false); const [searchParameters, setSearchParameters] = useSearchParams(); + const { verificationIdsMap, setVerificationId } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.Social]; + + // Google One Tap will mutate the verificationId after the initial render + // We need to store a up to date reference of the verificationId + const verificationIdRef = useRef(verificationId); const navigate = useNavigate(); const handleError = useErrorHandler(); const bindSocialRelatedUser = useBindSocialRelatedUser(); const registerWithSocial = useSocialRegister(connectorId, true); - const asyncSignInWithSocial = useApi(signInWithSocial); - const asyncPutInteraction = useApi(putInteraction); + const verifySocial = useApi(verifySocialVerification); + const asyncSignInWithSocial = useApi(identifyAndSubmitInteraction); + const asyncInitInteraction = useApi(initInteraction); const accountNotExistErrorHandler = useCallback( async (error: RequestErrorBody) => { const [, data] = validate(error.data, socialAccountNotExistErrorDataGuard); const { relatedUser } = data ?? {}; + const verificationId = verificationIdRef.current; + + // Redirect to sign-in page if the verificationId is not set properly + if (!verificationId) { + setToast(t('error.invalid_session')); + navigate('/' + experience.routes.signIn); + return; + } if (relatedUser) { if (socialSignInSettings.automaticAccountLinking) { - const { type, value } = relatedUser; - await bindSocialRelatedUser({ - connectorId, - ...(type === 'email' ? { email: value } : { phone: value }), - }); + await bindSocialRelatedUser(verificationId); } else { navigate(`/social/link/${connectorId}`, { replace: true, @@ -59,17 +81,30 @@ const useSocialSignInListener = (connectorId: string) => { } // Register with social - await registerWithSocial(connectorId); + await registerWithSocial(verificationId); }, [ bindSocialRelatedUser, connectorId, navigate, registerWithSocial, + setToast, socialSignInSettings.automaticAccountLinking, + t, ] ); + const globalErrorHandler = useMemo( + () => ({ + // Redirect to sign-in page if error is not handled by the error handlers + global: async (error) => { + setToast(error.message); + navigate('/' + experience.routes.signIn); + }, + }), + [navigate, setToast] + ); + const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); const signInWithSocialErrorHandlers: ErrorHandlers = useMemo( @@ -95,14 +130,11 @@ const useSocialSignInListener = (connectorId: string) => { await accountNotExistErrorHandler(error); }, ...preSignInErrorHandler, - // Redirect to sign-in page if error is not handled by the error handlers - global: async (error) => { - setToast(error.message); - navigate('/' + experience.routes.signIn); - }, + ...globalErrorHandler, }), [ preSignInErrorHandler, + globalErrorHandler, signInMode, agreeToTermsPolicy, termsValidation, @@ -112,15 +144,15 @@ const useSocialSignInListener = (connectorId: string) => { ] ); - const signInWithSocialHandler = useCallback( + const verifySocialCallbackData = useCallback( async (connectorId: string, data: Record) => { // When the callback is called from Google One Tap, the interaction event was not set yet. if (data[GoogleConnector.oneTapParams.csrfToken]) { - await asyncPutInteraction(InteractionEvent.SignIn); + await asyncInitInteraction(InteractionEvent.SignIn); } - const [error, result] = await asyncSignInWithSocial({ - connectorId, + const [error, result] = await verifySocial(connectorId, { + verificationId: verificationIdRef.current, connectorData: { // For validation use only redirectUri: `${window.location.origin}/callback/${connectorId}`, @@ -128,6 +160,35 @@ const useSocialSignInListener = (connectorId: string) => { }, }); + if (error || !result) { + setLoading(false); + await handleError(error, globalErrorHandler); + return; + } + + const { verificationId } = result; + + // VerificationId might not be available in the UserInteractionContext (Google one tap) + // Always update the verificationId here + // eslint-disable-next-line @silverhand/fp/no-mutation + verificationIdRef.current = verificationId; + setVerificationId(VerificationType.Social, verificationId); + + return verificationId; + }, + [asyncInitInteraction, globalErrorHandler, handleError, setVerificationId, verifySocial] + ); + + const signInWithSocialHandler = useCallback( + async (connectorId: string, data: Record) => { + const verificationId = await verifySocialCallbackData(connectorId, data); + + // Exception occurred during verification drop the process + if (!verificationId) { + return; + } + const [error, result] = await asyncSignInWithSocial({ verificationId }); + if (error) { setLoading(false); await handleError(error, signInWithSocialErrorHandlers); @@ -139,7 +200,7 @@ const useSocialSignInListener = (connectorId: string) => { window.location.replace(result.redirectTo); } }, - [asyncPutInteraction, asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers] + [asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers, verifySocialCallbackData] ); // Social Sign-in Callback Handler @@ -152,18 +213,25 @@ const useSocialSignInListener = (connectorId: string) => { const { state, ...rest } = parseQueryParameters(searchParameters); + const isGoogleOneTap = validateGoogleOneTapCsrfToken( + rest[GoogleConnector.oneTapParams.csrfToken] + ); + // Cleanup the search parameters once it's consumed setSearchParameters({}, { replace: true }); - if ( - !validateState(state, connectorId) && - !validateGoogleOneTapCsrfToken(rest[GoogleConnector.oneTapParams.csrfToken]) - ) { + if (!validateState(state, connectorId) && !isGoogleOneTap) { setToast(t('error.invalid_connector_auth')); navigate('/' + experience.routes.signIn); return; } + if (!verificationIdRef.current && !isGoogleOneTap) { + setToast(t('error.invalid_session')); + navigate('/' + experience.routes.signIn); + return; + } + void signInWithSocialHandler(connectorId, rest); }, [ connectorId, diff --git a/packages/experience/src/pages/VerificationCode/index.test.tsx b/packages/experience/src/pages/VerificationCode/index.test.tsx index 35f3ac1b606..aaa338171f1 100644 --- a/packages/experience/src/pages/VerificationCode/index.test.tsx +++ b/packages/experience/src/pages/VerificationCode/index.test.tsx @@ -1,4 +1,4 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, VerificationType } from '@logto/schemas'; import { renderHook } from '@testing-library/react'; import { Routes, Route } from 'react-router-dom'; import { remove } from 'tiny-cookie'; @@ -16,6 +16,7 @@ describe('VerificationCode Page', () => { beforeEach(() => { set(StorageKeys.IdentifierInputValue, { type: SignInIdentifier.Email, value: 'foo@logto.io' }); + set(StorageKeys.verificationIds, { [VerificationType.EmailVerificationCode]: 'foo' }); }); afterEach(() => { diff --git a/packages/experience/src/pages/VerificationCode/index.tsx b/packages/experience/src/pages/VerificationCode/index.tsx index 311eacede4c..cdf4b350755 100644 --- a/packages/experience/src/pages/VerificationCode/index.tsx +++ b/packages/experience/src/pages/VerificationCode/index.tsx @@ -1,4 +1,4 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, type VerificationCodeIdentifier } from '@logto/schemas'; import { t } from 'i18next'; import { useContext } from 'react'; import { useParams } from 'react-router-dom'; @@ -6,22 +6,33 @@ import { validate } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import VerificationCodeContainer from '@/containers/VerificationCode'; import { useSieMethods } from '@/hooks/use-sie'; import ErrorPage from '@/pages/ErrorPage'; import { UserFlow } from '@/types'; import { userFlowGuard } from '@/types/guard'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; +import { codeVerificationTypeMap } from '@/utils/sign-in-experience'; type Parameters = { flow: string; }; +const isValidVerificationCodeIdentifier = ( + identifierInputValue: IdentifierInputValue | undefined +): identifierInputValue is VerificationCodeIdentifier => + Boolean( + identifierInputValue?.type && + identifierInputValue.type !== SignInIdentifier.Username && + identifierInputValue.value + ); + const VerificationCode = () => { const { flow } = useParams(); const { signInMethods } = useSieMethods(); - const { identifierInputValue, forgotPasswordIdentifierInputValue } = + const { identifierInputValue, forgotPasswordIdentifierInputValue, verificationIdsMap } = useContext(UserInteractionContext); const [, userFlow] = validate(flow, userFlowGuard); @@ -33,19 +44,24 @@ const VerificationCode = () => { const cachedIdentifierInputValue = flow === UserFlow.ForgotPassword ? forgotPasswordIdentifierInputValue : identifierInputValue; - const { type, value } = cachedIdentifierInputValue ?? {}; - - if (!type || type === SignInIdentifier.Username || !value) { + if (!isValidVerificationCodeIdentifier(cachedIdentifierInputValue)) { return ; } - const methodSettings = signInMethods.find((method) => method.identifier === type); + const { type, value } = cachedIdentifierInputValue; // SignIn Method not enabled + const methodSettings = signInMethods.find((method) => method.identifier === type); if (!methodSettings && flow !== UserFlow.ForgotPassword) { return ; } + // VerificationId not found + const verificationId = verificationIdsMap[codeVerificationTypeMap[type]]; + if (!verificationId) { + return ; + } + return ( { > diff --git a/packages/experience/src/types/guard.test.ts b/packages/experience/src/types/guard.test.ts new file mode 100644 index 00000000000..1f2723f6d34 --- /dev/null +++ b/packages/experience/src/types/guard.test.ts @@ -0,0 +1,31 @@ +import { VerificationType } from '@logto/schemas'; +import * as s from 'superstruct'; + +import { verificationIdsMapGuard } from './guard'; + +describe('guard', () => { + it.each(Object.values(VerificationType))('verificationIdsMapGuard: %s', (type) => { + expect(() => { + s.assert({ [type]: 'verificationId' }, verificationIdsMapGuard); + }).not.toThrow(); + }); + + it('should throw with invalid key', () => { + expect(() => { + s.assert({ invalidKey: 'verificationId' }, verificationIdsMapGuard); + }).toThrow(); + }); + + it('should successfully parse the value', () => { + const record = { + [VerificationType.EmailVerificationCode]: 'verificationId', + [VerificationType.PhoneVerificationCode]: 'verificationId', + [VerificationType.Social]: 'verificationId', + }; + + const [error, value] = verificationIdsMapGuard.validate(record); + + expect(error).toBeUndefined(); + expect(value).toEqual(record); + }); +}); diff --git a/packages/experience/src/types/guard.ts b/packages/experience/src/types/guard.ts index 03837d4d68e..2e28a0b71dc 100644 --- a/packages/experience/src/types/guard.ts +++ b/packages/experience/src/types/guard.ts @@ -1,8 +1,10 @@ import { - SignInIdentifier, - MissingProfile, + InteractionEvent, MfaFactor, + MissingProfile, + SignInIdentifier, type SsoConnectorMetadata, + VerificationType, } from '@logto/schemas'; import * as s from 'superstruct'; @@ -81,12 +83,10 @@ export const totpBindingStateGuard = s.assign( export type TotpBindingState = s.Infer; -export const backupCodeErrorDataGuard = s.object({ +export const backupCodeBindingStateGuard = s.object({ codes: s.array(s.string()), }); -export const backupCodeBindingStateGuard = backupCodeErrorDataGuard; - export type BackupCodeBindingState = s.Infer; export const webAuthnStateGuard = s.assign( @@ -130,3 +130,29 @@ export const identifierInputValueGuard: s.Describe = s.obj * Type guard for the `identifier` search param config on the identifier sign-in/register page. */ export const identifierSearchParamGuard = s.array(identifierEnumGuard); + +type StringGuard = ReturnType; +// eslint-disable-next-line no-restricted-syntax -- Object.fromEntries can not infer the key type +const mapGuard = Object.fromEntries( + Object.values(VerificationType).map((type) => [type, s.string()]) +) as { [key in VerificationType]: StringGuard }; + +/** + * Defines the type guard for the verification ids map. + */ +export const verificationIdsMapGuard = s.partial(mapGuard); +export type VerificationIdsMap = s.Infer; + +/** + * Define the interaction event state guard. + * + * This is used to pass the current interaction event state to the continue flow page. + * + * - If is in the sign in flow, directly call the submitInteraction endpoint after the user completes the profile. + * - If is in the register flow, we need to call the identify endpoint first after the user completes the profile. + */ +export const continueFlowStateGuard = s.object({ + interactionEvent: s.enums([InteractionEvent.SignIn, InteractionEvent.Register]), +}); + +export type InteractionFlowState = s.Infer; diff --git a/packages/experience/src/types/index.ts b/packages/experience/src/types/index.ts index 667ad00e28a..df42ad62a94 100644 --- a/packages/experience/src/types/index.ts +++ b/packages/experience/src/types/index.ts @@ -4,6 +4,7 @@ import type { WebAuthnRegistrationOptions, WebAuthnAuthenticationOptions, FullSignInExperience, + InteractionEvent, } from '@logto/schemas'; export enum UserFlow { @@ -45,3 +46,5 @@ export type ArrayElement = ArrayType exten : never; export type WebAuthnOptions = WebAuthnRegistrationOptions | WebAuthnAuthenticationOptions; + +export type ContinueFlowInteractionEvent = InteractionEvent.Register | InteractionEvent.SignIn; diff --git a/packages/experience/src/utils/sign-in-experience.ts b/packages/experience/src/utils/sign-in-experience.ts index 6540c54fafd..b39109b4c50 100644 --- a/packages/experience/src/utils/sign-in-experience.ts +++ b/packages/experience/src/utils/sign-in-experience.ts @@ -3,7 +3,7 @@ * Remove this once we have a better way to get the sign in experience through SSR */ -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, VerificationType } from '@logto/schemas'; import { isObject } from '@silverhand/essentials'; import i18next from 'i18next'; @@ -68,3 +68,8 @@ export const parseHtmlTitle = (path: string) => { return 'Logto'; }; + +export const codeVerificationTypeMap = Object.freeze({ + [SignInIdentifier.Email]: VerificationType.EmailVerificationCode, + [SignInIdentifier.Phone]: VerificationType.PhoneVerificationCode, +}); From ad899d156a3e7a2916bcfd19bc3312d9c9c191f5 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 28 Aug 2024 14:02:21 +0800 Subject: [PATCH 4/8] chore: add changeset add changeset --- .changeset/fresh-shrimps-rhyme.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/fresh-shrimps-rhyme.md diff --git a/.changeset/fresh-shrimps-rhyme.md b/.changeset/fresh-shrimps-rhyme.md new file mode 100644 index 00000000000..bdd9502a5e4 --- /dev/null +++ b/.changeset/fresh-shrimps-rhyme.md @@ -0,0 +1,12 @@ +--- +"@logto/experience": minor +--- + +migrate experience app using Experience API. + +Migrate experience app API requests from legacy [Interaction API](https://openapi.logto.io/group/endpoint-interaction) to the new [Experience API](https://openapi.logto.io/group/endpoint-experience), except the following endpoints: + +- `GET /api/interaction/consent` +- `POST /api/interaction/consent` + +Those endpoints are used in the third-party application's consent page only. Remain unchanged. From 283a76dc55c2312c447a93f7b0650be82ad2d911 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 28 Aug 2024 15:43:26 +0800 Subject: [PATCH 5/8] fix(experience): comments fix comments fix --- packages/experience/src/apis/const.ts | 1 - packages/experience/src/apis/experience/index.ts | 12 ++++++------ .../experience/src/apis/experience/interaction.ts | 2 +- packages/experience/src/apis/experience/mfa.ts | 3 --- packages/experience/src/apis/experience/social.ts | 6 ++---- .../use-continue-flow-code-verification.ts | 8 ++++---- .../use-register-flow-code-verification.ts | 8 ++++---- .../experience/src/hooks/use-mfa-error-handler.ts | 5 +++-- .../src/hooks/use-start-backup-code-binding.ts | 5 +++-- .../src/pages/Continue/SetPassword/index.test.tsx | 6 +++--- .../src/pages/Continue/SetPassword/index.tsx | 4 ++-- .../src/pages/Continue/SetUsername/index.test.tsx | 6 +++--- .../pages/Continue/SetUsername/use-set-username.ts | 4 ++-- .../src/pages/SocialLinkAccount/index.test.tsx | 2 +- .../experience/src/pages/SocialLinkAccount/index.tsx | 2 +- .../experience/src/pages/VerificationCode/index.tsx | 2 +- 16 files changed, 36 insertions(+), 40 deletions(-) delete mode 100644 packages/experience/src/apis/const.ts diff --git a/packages/experience/src/apis/const.ts b/packages/experience/src/apis/const.ts deleted file mode 100644 index 992d54ce9b0..00000000000 --- a/packages/experience/src/apis/const.ts +++ /dev/null @@ -1 +0,0 @@ -export const kyPrefixUrl = '/'; diff --git a/packages/experience/src/apis/experience/index.ts b/packages/experience/src/apis/experience/index.ts index 4b4267548e9..b000a6570fb 100644 --- a/packages/experience/src/apis/experience/index.ts +++ b/packages/experience/src/apis/experience/index.ts @@ -15,7 +15,7 @@ import { identifyUser, submitInteraction, updateInteractionEvent, - _updateProfile, + updateProfile, identifyAndSubmitInteraction, } from './interaction'; @@ -55,11 +55,11 @@ export const signInWithPasswordIdentifier = async (payload: PasswordVerification export const registerWithUsername = async (username: string) => { await initInteraction(InteractionEvent.Register); - return _updateProfile({ type: SignInIdentifier.Username, value: username }); + return updateProfile({ type: SignInIdentifier.Username, value: username }); }; export const continueRegisterWithPassword = async (password: string) => { - await _updateProfile({ type: 'password', value: password }); + await updateProfile({ type: 'password', value: password }); return identifyAndSubmitInteraction(); }; @@ -108,7 +108,7 @@ export const updateProfileWithVerificationCode = async ( identifier: { type }, } = json; - await _updateProfile({ + await updateProfile({ type, verificationId, }); @@ -125,11 +125,11 @@ type UpdateProfilePayload = { value: string; }; -export const updateProfile = async ( +export const fulfillProfile = async ( payload: UpdateProfilePayload, interactionEvent: ContinueFlowInteractionEvent ) => { - await _updateProfile(payload); + await updateProfile(payload); if (interactionEvent === InteractionEvent.Register) { await identifyUser(); diff --git a/packages/experience/src/apis/experience/interaction.ts b/packages/experience/src/apis/experience/interaction.ts index 9e5a63685c2..ae763545daf 100644 --- a/packages/experience/src/apis/experience/interaction.ts +++ b/packages/experience/src/apis/experience/interaction.ts @@ -25,7 +25,7 @@ export const identifyUser = async (payload: IdentificationApiPayload = {}) => export const submitInteraction = async () => api.post(`${experienceApiRoutes.submit}`).json(); -export const _updateProfile = async (payload: UpdateProfileApiPayload) => +export const updateProfile = async (payload: UpdateProfileApiPayload) => api.post(experienceApiRoutes.profile, { json: payload }); export const updateInteractionEvent = async (interactionEvent: InteractionEvent) => diff --git a/packages/experience/src/apis/experience/mfa.ts b/packages/experience/src/apis/experience/mfa.ts index f2ab7b5e5ee..a0a9c0b558c 100644 --- a/packages/experience/src/apis/experience/mfa.ts +++ b/packages/experience/src/apis/experience/mfa.ts @@ -11,9 +11,6 @@ import api from '../api'; import { experienceApiRoutes } from './const'; import { submitInteraction } from './interaction'; -/** - * Mfa APIs - */ const addMfa = async (type: MfaFactor, verificationId: string) => api.post(`${experienceApiRoutes.mfa}`, { json: { diff --git a/packages/experience/src/apis/experience/social.ts b/packages/experience/src/apis/experience/social.ts index 95780446a3e..ce50741a383 100644 --- a/packages/experience/src/apis/experience/social.ts +++ b/packages/experience/src/apis/experience/social.ts @@ -1,5 +1,3 @@ -// Social and SSO APIs - import { InteractionEvent, type SocialVerificationCallbackPayload } from '@logto/schemas'; import api from '../api'; @@ -11,7 +9,7 @@ import { updateInteractionEvent, identifyUser, submitInteraction, - _updateProfile, + updateProfile, } from './interaction'; export const getSocialAuthorizationUrl = async ( @@ -91,6 +89,6 @@ export const signInAndLinkWithSocial = async ( ) => { await updateInteractionEvent(InteractionEvent.SignIn); await identifyUser({ verificationId }); - await _updateProfile({ type: 'social', verificationId: socialVerificationid }); + await updateProfile({ type: 'social', verificationId: socialVerificationid }); return submitInteraction(); }; diff --git a/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts index 51aea127223..292e1ba707f 100644 --- a/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts @@ -42,7 +42,7 @@ const useContinueFlowCodeVerification = ( const showIdentifierErrorAlert = useIdentifierErrorAlert(); const showLinkSocialConfirmModal = useLinkSocialConfirmModal(); - const identifierExistErrorHandler = useCallback(async () => { + const identifierExistsErrorHandler = useCallback(async () => { const linkSocial = searchParameters.get(SearchParameters.LinkSocial); const socialVerificationId = verificationIdsMap[VerificationType.Social]; @@ -65,12 +65,12 @@ const useContinueFlowCodeVerification = ( const verifyVerificationCodeErrorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.phone_already_in_use': identifierExistErrorHandler, - 'user.email_already_in_use': identifierExistErrorHandler, + 'user.phone_already_in_use': identifierExistsErrorHandler, + 'user.email_already_in_use': identifierExistsErrorHandler, ...preSignInErrorHandler, ...generalVerificationCodeErrorHandlers, }), - [preSignInErrorHandler, generalVerificationCodeErrorHandlers, identifierExistErrorHandler] + [preSignInErrorHandler, generalVerificationCodeErrorHandlers, identifierExistsErrorHandler] ); const onSubmit = useCallback( diff --git a/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts index de2a1aedb68..b6728c777d4 100644 --- a/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-register-flow-code-verification.ts @@ -52,7 +52,7 @@ const useRegisterFlowCodeVerification = ( const showIdentifierErrorAlert = useIdentifierErrorAlert(); - const identifierExistErrorHandler = useCallback(async () => { + const identifierExistsErrorHandler = useCallback(async () => { const { type, value } = identifier; // Should not redirect user to sign-in if is register-only mode @@ -102,14 +102,14 @@ const useRegisterFlowCodeVerification = ( const errorHandlers = useMemo( () => ({ - 'user.email_already_in_use': identifierExistErrorHandler, - 'user.phone_already_in_use': identifierExistErrorHandler, + 'user.email_already_in_use': identifierExistsErrorHandler, + 'user.phone_already_in_use': identifierExistsErrorHandler, ...generalVerificationCodeErrorHandlers, ...preRegisterErrorHandler, callback: errorCallback, }), [ - identifierExistErrorHandler, + identifierExistsErrorHandler, generalVerificationCodeErrorHandlers, preRegisterErrorHandler, errorCallback, diff --git a/packages/experience/src/hooks/use-mfa-error-handler.ts b/packages/experience/src/hooks/use-mfa-error-handler.ts index 67bb715e35c..f5c6b587aa6 100644 --- a/packages/experience/src/hooks/use-mfa-error-handler.ts +++ b/packages/experience/src/hooks/use-mfa-error-handler.ts @@ -9,12 +9,13 @@ import { type MfaFlowState, mfaErrorDataGuard } from '@/types/guard'; import { isNativeWebview } from '@/utils/native-sdk'; import type { ErrorHandlers } from './use-error-handler'; -import useBackupCodeBinding from './use-start-backup-code-binding'; +import useStartBackupCodeBinding from './use-start-backup-code-binding'; import useStartTotpBinding from './use-start-totp-binding'; import useStartWebAuthnProcessing from './use-start-webauthn-processing'; import useToast from './use-toast'; export type Options = { + /** Whether to replace the current page in the history stack. */ replace?: boolean; }; @@ -24,7 +25,7 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { const { setToast } = useToast(); const startTotpBinding = useStartTotpBinding({ replace }); const startWebAuthnProcessing = useStartWebAuthnProcessing({ replace }); - const startBackupCodeBinding = useBackupCodeBinding({ replace }); + const startBackupCodeBinding = useStartBackupCodeBinding({ replace }); /** * Redirect the user to the corresponding MFA page. diff --git a/packages/experience/src/hooks/use-start-backup-code-binding.ts b/packages/experience/src/hooks/use-start-backup-code-binding.ts index 71005985098..5428c10dfda 100644 --- a/packages/experience/src/hooks/use-start-backup-code-binding.ts +++ b/packages/experience/src/hooks/use-start-backup-code-binding.ts @@ -11,10 +11,11 @@ import useApi from './use-api'; import useErrorHandler from './use-error-handler'; type Options = { + /** Whether to replace the current page in the history stack. */ replace?: boolean; }; -const useBackupCodeBinding = ({ replace }: Options = {}) => { +const useStartBackupCodeBinding = ({ replace }: Options = {}) => { const navigate = useNavigate(); const generateBackUpCodes = useApi(createBackupCode); const { setVerificationId } = useContext(UserInteractionContext); @@ -43,4 +44,4 @@ const useBackupCodeBinding = ({ replace }: Options = {}) => { }, [generateBackUpCodes, handleError, navigate, replace, setVerificationId]); }; -export default useBackupCodeBinding; +export default useStartBackupCodeBinding; diff --git a/packages/experience/src/pages/Continue/SetPassword/index.test.tsx b/packages/experience/src/pages/Continue/SetPassword/index.test.tsx index 6f49aec74e2..01f47020c1c 100644 --- a/packages/experience/src/pages/Continue/SetPassword/index.test.tsx +++ b/packages/experience/src/pages/Continue/SetPassword/index.test.tsx @@ -4,7 +4,7 @@ import { act, waitFor, fireEvent } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings } from '@/__mocks__/logto'; -import { updateProfile } from '@/apis/experience'; +import { fulfillProfile } from '@/apis/experience'; import SetPassword from '.'; @@ -16,7 +16,7 @@ jest.mock('react-router-dom', () => ({ })); jest.mock('@/apis/experience', () => ({ - updateProfile: jest.fn(async () => ({ redirectTo: '/' })), + fulfillProfile: jest.fn(async () => ({ redirectTo: '/' })), })); describe('SetPassword', () => { @@ -116,7 +116,7 @@ describe('SetPassword', () => { }); await waitFor(() => { - expect(updateProfile).toBeCalledWith( + expect(fulfillProfile).toBeCalledWith( { type: 'password', value: '1234!@#$', diff --git a/packages/experience/src/pages/Continue/SetPassword/index.tsx b/packages/experience/src/pages/Continue/SetPassword/index.tsx index 5ca612aa4c8..24f92eeaa84 100644 --- a/packages/experience/src/pages/Continue/SetPassword/index.tsx +++ b/packages/experience/src/pages/Continue/SetPassword/index.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; -import { updateProfile } from '@/apis/experience'; +import { fulfillProfile } from '@/apis/experience'; import SetPasswordForm from '@/containers/SetPassword'; import useApi from '@/hooks/use-api'; import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; @@ -31,7 +31,7 @@ const SetPassword = ({ interactionEvent }: Props) => { const redirectTo = useGlobalRedirectTo(); const checkPassword = usePasswordPolicyChecker({ setErrorMessage }); - const addPassword = useApi(updateProfile); + const addPassword = useApi(fulfillProfile); const handleError = useErrorHandler(); const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage }); diff --git a/packages/experience/src/pages/Continue/SetUsername/index.test.tsx b/packages/experience/src/pages/Continue/SetUsername/index.test.tsx index 0572402b9cb..b6668bcc056 100644 --- a/packages/experience/src/pages/Continue/SetUsername/index.test.tsx +++ b/packages/experience/src/pages/Continue/SetUsername/index.test.tsx @@ -3,7 +3,7 @@ import { act, waitFor, fireEvent } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; -import { updateProfile } from '@/apis/experience'; +import { fulfillProfile } from '@/apis/experience'; import SetUsername from '.'; @@ -21,7 +21,7 @@ jest.mock('react-router-dom', () => ({ })); jest.mock('@/apis/experience', () => ({ - updateProfile: jest.fn(async () => ({ redirectTo: '/' })), + fulfillProfile: jest.fn(async () => ({ redirectTo: '/' })), })); describe('SetUsername', () => { @@ -53,7 +53,7 @@ describe('SetUsername', () => { }); await waitFor(() => { - expect(updateProfile).toBeCalledWith( + expect(fulfillProfile).toBeCalledWith( { type: SignInIdentifier.Username, value: 'username' }, InteractionEvent.Register ); diff --git a/packages/experience/src/pages/Continue/SetUsername/use-set-username.ts b/packages/experience/src/pages/Continue/SetUsername/use-set-username.ts index 4dbeea20ed7..1e3eaf78d70 100644 --- a/packages/experience/src/pages/Continue/SetUsername/use-set-username.ts +++ b/packages/experience/src/pages/Continue/SetUsername/use-set-username.ts @@ -1,7 +1,7 @@ import { SignInIdentifier } from '@logto/schemas'; import { useCallback, useMemo, useState } from 'react'; -import { updateProfile } from '@/apis/experience'; +import { fulfillProfile } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; @@ -16,7 +16,7 @@ const useSetUsername = (interactionEvent: ContinueFlowInteractionEvent) => { setErrorMessage(''); }, []); - const asyncAddProfile = useApi(updateProfile); + const asyncAddProfile = useApi(fulfillProfile); const handleError = useErrorHandler(); const redirectTo = useGlobalRedirectTo(); diff --git a/packages/experience/src/pages/SocialLinkAccount/index.test.tsx b/packages/experience/src/pages/SocialLinkAccount/index.test.tsx index 0bad1d20898..b2cca92acb6 100644 --- a/packages/experience/src/pages/SocialLinkAccount/index.test.tsx +++ b/packages/experience/src/pages/SocialLinkAccount/index.test.tsx @@ -19,7 +19,7 @@ jest.mock('react-router-dom', () => ({ const verificationIdsMap = { [VerificationType.Social]: 'foo' }; -describe('SocialRegister', () => { +describe('SocialLinkAccount', () => { const { result } = renderHook(() => useSessionStorage()); const { set } = result.current; diff --git a/packages/experience/src/pages/SocialLinkAccount/index.tsx b/packages/experience/src/pages/SocialLinkAccount/index.tsx index ac60569813b..0b4c78740ca 100644 --- a/packages/experience/src/pages/SocialLinkAccount/index.tsx +++ b/packages/experience/src/pages/SocialLinkAccount/index.tsx @@ -50,7 +50,7 @@ const SocialLinkAccount = () => { } if (!verificationId) { - return ; + return ; } const { relatedUser } = state; diff --git a/packages/experience/src/pages/VerificationCode/index.tsx b/packages/experience/src/pages/VerificationCode/index.tsx index cdf4b350755..7737c335363 100644 --- a/packages/experience/src/pages/VerificationCode/index.tsx +++ b/packages/experience/src/pages/VerificationCode/index.tsx @@ -59,7 +59,7 @@ const VerificationCode = () => { // VerificationId not found const verificationId = verificationIdsMap[codeVerificationTypeMap[type]]; if (!verificationId) { - return ; + return ; } return ( From b02649d897ced0c1e2ca455f9ec8185e89043ecf Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 28 Aug 2024 16:32:22 +0800 Subject: [PATCH 6/8] refactor(experience): refactor the code verificatin api refactor the code verification api --- packages/experience/src/apis/utils.ts | 69 +++++++++---------- .../VerificationCode/index.test.tsx | 12 ++-- .../use-continue-flow-code-verification.ts | 6 +- .../use-resend-verification-code.ts | 35 ++++++++-- 4 files changed, 69 insertions(+), 53 deletions(-) diff --git a/packages/experience/src/apis/utils.ts b/packages/experience/src/apis/utils.ts index 0f9935a8458..0d5ef846fbe 100644 --- a/packages/experience/src/apis/utils.ts +++ b/packages/experience/src/apis/utils.ts @@ -1,51 +1,44 @@ import { InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas'; +import { validate } from 'superstruct'; import { type ContinueFlowInteractionEvent, UserFlow } from '@/types'; +import { continueFlowStateGuard } from '@/types/guard'; import { initInteraction, sendVerificationCode } from './experience'; -/** Move to API */ +// Consider deprecate this, remove the `UserFlow.Continue` case +// Align the flow definition with the interaction event +export const userFlowToInteractionEventMap = Object.freeze({ + [UserFlow.SignIn]: InteractionEvent.SignIn, + [UserFlow.Register]: InteractionEvent.Register, + [UserFlow.ForgotPassword]: InteractionEvent.ForgotPassword, +}); + +/** + * This method is used to get the interaction event from the location state + * For continue flow, the interaction event is stored in the location state, + * we need to retrieve it from the state in order to send the verification code with the correct interaction event template + */ +export const getInteractionEventFromState = (state: unknown) => { + if (!state) { + return; + } + + const [, continueFlowState] = validate(state, continueFlowStateGuard); + + return continueFlowState?.interactionEvent; +}; + export const sendVerificationCodeApi = async ( - type: UserFlow, + flow: UserFlow, identifier: VerificationCodeIdentifier, interactionEvent?: ContinueFlowInteractionEvent ) => { - switch (type) { - case UserFlow.SignIn: { - await initInteraction(InteractionEvent.SignIn); - return sendVerificationCode(InteractionEvent.SignIn, identifier); - } - case UserFlow.Register: { - await initInteraction(InteractionEvent.Register); - return sendVerificationCode(InteractionEvent.Register, identifier); - } - case UserFlow.ForgotPassword: { - await initInteraction(InteractionEvent.ForgotPassword); - return sendVerificationCode(InteractionEvent.ForgotPassword, identifier); - } - case UserFlow.Continue: { - return sendVerificationCode(interactionEvent ?? InteractionEvent.SignIn, identifier); - } + if (flow === UserFlow.Continue) { + return sendVerificationCode(interactionEvent ?? InteractionEvent.SignIn, identifier); } -}; -export const resendVerificationCodeApi = async ( - type: UserFlow, - identifier: VerificationCodeIdentifier -) => { - switch (type) { - case UserFlow.SignIn: { - return sendVerificationCode(InteractionEvent.SignIn, identifier); - } - case UserFlow.Register: { - return sendVerificationCode(InteractionEvent.Register, identifier); - } - case UserFlow.ForgotPassword: { - return sendVerificationCode(InteractionEvent.ForgotPassword, identifier); - } - case UserFlow.Continue: { - // Continue flow does not have its own email template, always use sign-in template for now - return sendVerificationCode(InteractionEvent.SignIn, identifier); - } - } + const event = userFlowToInteractionEventMap[flow]; + await initInteraction(event); + return sendVerificationCode(event, identifier); }; diff --git a/packages/experience/src/containers/VerificationCode/index.test.tsx b/packages/experience/src/containers/VerificationCode/index.test.tsx index ee37b49c0e2..3d8e21751b3 100644 --- a/packages/experience/src/containers/VerificationCode/index.test.tsx +++ b/packages/experience/src/containers/VerificationCode/index.test.tsx @@ -7,8 +7,11 @@ import { import { act, fireEvent, waitFor } from '@testing-library/react'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; -import { identifyWithVerificationCode, updateProfileWithVerificationCode } from '@/apis/experience'; -import { resendVerificationCodeApi } from '@/apis/utils'; +import { + identifyWithVerificationCode, + updateProfileWithVerificationCode, + sendVerificationCode, +} from '@/apis/experience'; import { setupI18nForTesting } from '@/jest.setup'; import { UserFlow } from '@/types'; @@ -29,11 +32,12 @@ jest.mock('react-router-dom', () => ({ })); jest.mock('@/apis/utils', () => ({ + ...jest.requireActual('@/apis/utils'), sendVerificationCodeApi: jest.fn(), - resendVerificationCodeApi: jest.fn(), })); jest.mock('@/apis/experience', () => ({ + sendVerificationCode: jest.fn(), identifyWithVerificationCode: jest.fn().mockResolvedValue({ redirectTo: '/redirect' }), updateProfileWithVerificationCode: jest.fn().mockResolvedValue({ redirectTo: '/redirect' }), })); @@ -123,7 +127,7 @@ describe('', () => { fireEvent.click(resendButton); }); - expect(resendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, emailIdentifier); + expect(sendVerificationCode).toBeCalledWith(InteractionEvent.SignIn, emailIdentifier); // Reset i18n await setupI18nForTesting(); diff --git a/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts b/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts index 292e1ba707f..e338bb85501 100644 --- a/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts +++ b/packages/experience/src/containers/VerificationCode/use-continue-flow-code-verification.ts @@ -2,17 +2,16 @@ import type { VerificationCodeIdentifier } from '@logto/schemas'; import { VerificationType } from '@logto/schemas'; import { useCallback, useContext, useMemo } from 'react'; import { useLocation, useSearchParams } from 'react-router-dom'; -import { validate } from 'superstruct'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import { updateProfileWithVerificationCode } from '@/apis/experience'; +import { getInteractionEventFromState } from '@/apis/utils'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import { SearchParameters } from '@/types'; -import { continueFlowStateGuard } from '@/types/guard'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; @@ -27,9 +26,8 @@ const useContinueFlowCodeVerification = ( const redirectTo = useGlobalRedirectTo(); const { state } = useLocation(); - const [, continueFlowState] = validate(state, continueFlowStateGuard); const { verificationIdsMap } = useContext(UserInteractionContext); - const interactionEvent = continueFlowState?.interactionEvent; + const interactionEvent = getInteractionEventFromState(state); const handleError = useErrorHandler(); const verifyVerificationCode = useApi(updateProfileWithVerificationCode); diff --git a/packages/experience/src/containers/VerificationCode/use-resend-verification-code.ts b/packages/experience/src/containers/VerificationCode/use-resend-verification-code.ts index 29956c66ebd..1024501cd21 100644 --- a/packages/experience/src/containers/VerificationCode/use-resend-verification-code.ts +++ b/packages/experience/src/containers/VerificationCode/use-resend-verification-code.ts @@ -1,14 +1,16 @@ -import { type VerificationCodeIdentifier } from '@logto/schemas'; +import { InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas'; import { t } from 'i18next'; -import { useCallback, useContext } from 'react'; +import { useCallback, useContext, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; import { useTimer } from 'react-timer-hook'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; -import { resendVerificationCodeApi } from '@/apis/utils'; +import { sendVerificationCode } from '@/apis/experience'; +import { getInteractionEventFromState, userFlowToInteractionEventMap } from '@/apis/utils'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useToast from '@/hooks/use-toast'; -import type { UserFlow } from '@/types'; +import { UserFlow } from '@/types'; import { codeVerificationTypeMap } from '@/utils/sign-in-experience'; export const timeRange = 59; @@ -22,6 +24,17 @@ const getTimeout = () => { const useResendVerificationCode = (flow: UserFlow, identifier: VerificationCodeIdentifier) => { const { setToast } = useToast(); + const { state } = useLocation(); + + const interactionEvent = useMemo(() => { + if (flow === UserFlow.Continue) { + const interactionEvent = getInteractionEventFromState(state); + console.log('interactionEvent', interactionEvent); + return interactionEvent ?? InteractionEvent.SignIn; + } + + return userFlowToInteractionEventMap[flow]; + }, [flow, state]); const { seconds, isRunning, restart } = useTimer({ autoStart: true, @@ -29,11 +42,11 @@ const useResendVerificationCode = (flow: UserFlow, identifier: VerificationCodeI }); const handleError = useErrorHandler(); - const sendVerificationCode = useApi(resendVerificationCodeApi); + const resendVerificationCode = useApi(sendVerificationCode); const { setVerificationId } = useContext(UserInteractionContext); const onResendVerificationCode = useCallback(async () => { - const [error, result] = await sendVerificationCode(flow, identifier); + const [error, result] = await resendVerificationCode(interactionEvent, identifier); if (error) { await handleError(error); @@ -47,7 +60,15 @@ const useResendVerificationCode = (flow: UserFlow, identifier: VerificationCodeI setToast(t('description.passcode_sent')); restart(getTimeout(), true); } - }, [flow, handleError, identifier, restart, sendVerificationCode, setToast, setVerificationId]); + }, [ + resendVerificationCode, + interactionEvent, + identifier, + handleError, + setVerificationId, + setToast, + restart, + ]); return { seconds, From 0e00fb4548829b38daf502a13c2839305eb9cda6 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 4 Sep 2024 15:32:38 +0800 Subject: [PATCH 7/8] refactor(experience): code refactor refactor some implementation logic --- .../src/hooks/use-mfa-error-handler.ts | 16 +++--- .../hooks/use-start-backup-code-binding.ts | 50 +++++++++---------- .../src/hooks/use-start-totp-binding.ts | 10 ++-- .../hooks/use-start-webauthn-processing.ts | 9 +--- .../use-social-sign-in-listener.ts | 17 +++---- 5 files changed, 44 insertions(+), 58 deletions(-) diff --git a/packages/experience/src/hooks/use-mfa-error-handler.ts b/packages/experience/src/hooks/use-mfa-error-handler.ts index f5c6b587aa6..922c0f6ef2f 100644 --- a/packages/experience/src/hooks/use-mfa-error-handler.ts +++ b/packages/experience/src/hooks/use-mfa-error-handler.ts @@ -15,7 +15,7 @@ import useStartWebAuthnProcessing from './use-start-webauthn-processing'; import useToast from './use-toast'; export type Options = { - /** Whether to replace the current page in the history stack. */ + /** Whether to replace the current page in the history stack on navigation. */ replace?: boolean; }; @@ -23,9 +23,9 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { const navigate = useNavigate(); const { t } = useTranslation(); const { setToast } = useToast(); - const startTotpBinding = useStartTotpBinding({ replace }); - const startWebAuthnProcessing = useStartWebAuthnProcessing({ replace }); - const startBackupCodeBinding = useStartBackupCodeBinding({ replace }); + const startTotpBinding = useStartTotpBinding(); + const startWebAuthnProcessing = useStartWebAuthnProcessing(); + const startBackupCodeBinding = useStartBackupCodeBinding(); /** * Redirect the user to the corresponding MFA page. @@ -74,14 +74,14 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { /** * Start TOTP binding process if only TOTP is available. */ - return startTotpBinding(state); + return startTotpBinding(state, replace); } if (factor === MfaFactor.WebAuthn) { /** * Start WebAuthn processing if only TOTP is available. */ - return startWebAuthnProcessing(flow, state); + return startWebAuthnProcessing(flow, state, replace); } /** @@ -120,9 +120,9 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { () => ({ 'user.missing_mfa': handleMfaError(UserMfaFlow.MfaBinding), 'session.mfa.require_mfa_verification': handleMfaError(UserMfaFlow.MfaVerification), - 'session.mfa.backup_code_required': startBackupCodeBinding, + 'session.mfa.backup_code_required': async () => startBackupCodeBinding(replace), }), - [handleMfaError, startBackupCodeBinding] + [handleMfaError, replace, startBackupCodeBinding] ); return mfaVerificationErrorHandler; diff --git a/packages/experience/src/hooks/use-start-backup-code-binding.ts b/packages/experience/src/hooks/use-start-backup-code-binding.ts index 5428c10dfda..6eeff0186cd 100644 --- a/packages/experience/src/hooks/use-start-backup-code-binding.ts +++ b/packages/experience/src/hooks/use-start-backup-code-binding.ts @@ -10,38 +10,36 @@ import { type BackupCodeBindingState } from '@/types/guard'; import useApi from './use-api'; import useErrorHandler from './use-error-handler'; -type Options = { - /** Whether to replace the current page in the history stack. */ - replace?: boolean; -}; - -const useStartBackupCodeBinding = ({ replace }: Options = {}) => { +const useStartBackupCodeBinding = () => { const navigate = useNavigate(); const generateBackUpCodes = useApi(createBackupCode); const { setVerificationId } = useContext(UserInteractionContext); const handleError = useErrorHandler(); - return useCallback(async () => { - const [error, result] = await generateBackUpCodes(); - - if (error) { - await handleError(error); - return; - } - - if (!result) { - return; - } - - const { verificationId, codes } = result; - setVerificationId(VerificationType.BackupCode, verificationId); - - navigate( - { pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.BackupCode}` }, - { replace, state: { codes } satisfies BackupCodeBindingState } - ); - }, [generateBackUpCodes, handleError, navigate, replace, setVerificationId]); + return useCallback( + async (replace?: boolean) => { + const [error, result] = await generateBackUpCodes(); + + if (error) { + await handleError(error); + return; + } + + if (!result) { + return; + } + + const { verificationId, codes } = result; + setVerificationId(VerificationType.BackupCode, verificationId); + + navigate( + { pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.BackupCode}` }, + { replace, state: { codes } satisfies BackupCodeBindingState } + ); + }, + [generateBackUpCodes, handleError, navigate, setVerificationId] + ); }; export default useStartBackupCodeBinding; diff --git a/packages/experience/src/hooks/use-start-totp-binding.ts b/packages/experience/src/hooks/use-start-totp-binding.ts index b06087a1ef4..11dc337e16c 100644 --- a/packages/experience/src/hooks/use-start-totp-binding.ts +++ b/packages/experience/src/hooks/use-start-totp-binding.ts @@ -9,11 +9,7 @@ import useErrorHandler from '@/hooks/use-error-handler'; import { UserMfaFlow } from '@/types'; import { type MfaFlowState, type TotpBindingState } from '@/types/guard'; -type Options = { - replace?: boolean; -}; - -const useStartTotpBinding = ({ replace }: Options = {}) => { +const useStartTotpBinding = () => { const navigate = useNavigate(); const asyncCreateTotpSecret = useApi(createTotpSecret); const { setVerificationId } = useContext(UserInteractionContext); @@ -21,7 +17,7 @@ const useStartTotpBinding = ({ replace }: Options = {}) => { const handleError = useErrorHandler(); return useCallback( - async (flowState: MfaFlowState) => { + async (flowState: MfaFlowState, replace?: boolean) => { const [error, result] = await asyncCreateTotpSecret(); if (error) { @@ -42,7 +38,7 @@ const useStartTotpBinding = ({ replace }: Options = {}) => { navigate({ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.TOTP}` }, { replace, state }); } }, - [asyncCreateTotpSecret, handleError, navigate, replace, setVerificationId] + [asyncCreateTotpSecret, handleError, navigate, setVerificationId] ); }; diff --git a/packages/experience/src/hooks/use-start-webauthn-processing.ts b/packages/experience/src/hooks/use-start-webauthn-processing.ts index d46d2bf9488..78831c91fd6 100644 --- a/packages/experience/src/hooks/use-start-webauthn-processing.ts +++ b/packages/experience/src/hooks/use-start-webauthn-processing.ts @@ -10,11 +10,7 @@ import { type WebAuthnState, type MfaFlowState } from '@/types/guard'; import useApi from './use-api'; import useErrorHandler from './use-error-handler'; -type Options = { - replace?: boolean; -}; - -const useStartWebAuthnProcessing = ({ replace }: Options = {}) => { +const useStartWebAuthnProcessing = () => { const navigate = useNavigate(); const asyncCreateRegistrationOptions = useApi(createWebAuthnRegistration); const asyncGenerateAuthnOptions = useApi(createWebAuthnAuthentication); @@ -22,7 +18,7 @@ const useStartWebAuthnProcessing = ({ replace }: Options = {}) => { const { setVerificationId } = useContext(UserInteractionContext); return useCallback( - async (flow: UserMfaFlow, flowState: MfaFlowState) => { + async (flow: UserMfaFlow, flowState: MfaFlowState, replace?: boolean) => { const [error, result] = flow === UserMfaFlow.MfaBinding ? await asyncCreateRegistrationOptions() @@ -50,7 +46,6 @@ const useStartWebAuthnProcessing = ({ replace }: Options = {}) => { asyncGenerateAuthnOptions, handleError, navigate, - replace, setVerificationId, ] ); diff --git a/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts index 929714fa3fa..5e757a43ac2 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts +++ b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts @@ -94,14 +94,11 @@ const useSocialSignInListener = (connectorId: string) => { ] ); - const globalErrorHandler = useMemo( - () => ({ - // Redirect to sign-in page if error is not handled by the error handlers - global: async (error) => { - setToast(error.message); - navigate('/' + experience.routes.signIn); - }, - }), + const globalErrorHandler = useCallback( + async (error: RequestErrorBody) => { + setToast(error.message); + navigate('/' + experience.routes.signIn); + }, [navigate, setToast] ); @@ -130,7 +127,7 @@ const useSocialSignInListener = (connectorId: string) => { await accountNotExistErrorHandler(error); }, ...preSignInErrorHandler, - ...globalErrorHandler, + global: globalErrorHandler, }), [ preSignInErrorHandler, @@ -162,7 +159,7 @@ const useSocialSignInListener = (connectorId: string) => { if (error || !result) { setLoading(false); - await handleError(error, globalErrorHandler); + await handleError(error, { global: globalErrorHandler }); return; } From 159fd5ebdb3dbab5e38f6e0a2d0376211ecbdd97 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Thu, 5 Sep 2024 10:07:26 +0800 Subject: [PATCH 8/8] feat(experience, core): add experience legacy package (#6527) add experience legacy package --- package.json | 2 +- packages/core/package.json | 1 + packages/core/src/middleware/koa-spa-proxy.ts | 2 +- packages/experience-legacy/.eslintrc.cjs | 22 + packages/experience-legacy/CHANGELOG.md | 727 ++++++++++++++++++ packages/experience-legacy/README.md | 3 + packages/experience-legacy/index.html | 20 + packages/experience-legacy/jest.config.ts | 35 + packages/experience-legacy/package.json | 99 +++ packages/experience-legacy/src/App.tsx | 168 ++++ .../src/Layout/AppLayout/CustomContent.tsx | 28 + .../src/Layout/AppLayout/index.module.scss | 66 ++ .../src/Layout/AppLayout/index.tsx | 29 + .../FirstScreenLayout/index.module.scss | 25 + .../src/Layout/FirstScreenLayout/index.tsx | 28 + .../FocusedAuthPageLayout/index.module.scss | 33 + .../Layout/FocusedAuthPageLayout/index.tsx | 61 ++ .../LandingPageLayout/index.module.scss | 36 + .../src/Layout/LandingPageLayout/index.tsx | 54 ++ .../SecondaryPageLayout/index.module.scss | 53 ++ .../src/Layout/SecondaryPageLayout/index.tsx | 67 ++ .../Layout/SectionLayout/index.module.scss | 11 + .../src/Layout/SectionLayout/index.tsx | 30 + .../Layout/StaticPageLayout/index.module.scss | 7 + .../src/Layout/StaticPageLayout/index.tsx | 11 + .../src/Providers/AppBoundary/AppMeta.tsx | 56 ++ .../Providers/AppBoundary/index.module.scss | 37 + .../src/Providers/AppBoundary/index.tsx | 30 + .../Providers/AppBoundary/use-color-theme.ts | 62 ++ .../Providers/ConfirmModalProvider/index.tsx | 161 ++++ .../ConfirmModalProvider/indext.test.tsx | 203 +++++ .../IframeModal/index.module.scss | 71 ++ .../IframeModalProvider/IframeModal/index.tsx | 71 ++ .../Providers/IframeModalProvider/index.tsx | 55 ++ .../Providers/LoadingLayerProvider/index.tsx | 18 + .../PageContextProvider/PageContext.tsx | 38 + .../Providers/PageContextProvider/index.tsx | 66 ++ .../src/Providers/SettingsProvider/index.tsx | 22 + .../Providers/SettingsProvider/use-preview.ts | 83 ++ .../use-sign-in-experience.ts | 24 + .../Providers/SettingsProvider/use-theme.ts | 30 + .../SingleSignOnFormModeContext.tsx | 18 + .../index.tsx | 23 + .../src/Providers/ToastProvider/index.tsx | 27 + .../UserInteractionContext.tsx | 58 ++ .../UserInteractionContextProvider/index.tsx | 114 +++ .../SettingsProvider.tsx | 29 + .../__mocks__/RenderWithPageContext/index.tsx | 24 + .../experience-legacy/src/__mocks__/logto.tsx | 221 ++++++ .../src/__mocks__/social-connectors.tsx | 124 +++ packages/experience-legacy/src/apis/api.ts | 12 + .../experience-legacy/src/apis/consent.ts | 21 + .../experience-legacy/src/apis/interaction.ts | 262 +++++++ .../experience-legacy/src/apis/settings.ts | 63 ++ .../src/apis/single-sign-on.ts | 53 ++ packages/experience-legacy/src/apis/utils.ts | 26 + .../src/assets/apple-touch-icon.png | Bin 0 -> 83873 bytes .../experience-legacy/src/assets/favicon.png | Bin 0 -> 592 bytes .../src/assets/icons/arrow-down.svg | 3 + .../src/assets/icons/arrow-next.svg | 3 + .../src/assets/icons/arrow-prev.svg | 3 + .../src/assets/icons/check-mark.svg | 3 + .../src/assets/icons/checkbox-icon.svg | 5 + .../src/assets/icons/clear-icon.svg | 4 + .../src/assets/icons/close-icon.svg | 4 + .../src/assets/icons/connect-icon.svg | 6 + .../src/assets/icons/default-user-avatar.svg | 7 + .../src/assets/icons/dev-icon.svg | 5 + .../src/assets/icons/empty-state-dark.svg | 58 ++ .../src/assets/icons/empty-state.svg | 61 ++ .../src/assets/icons/expand-icon.svg | 4 + .../src/assets/icons/expandable-icon.svg | 4 + .../src/assets/icons/factor-backup-code.svg | 3 + .../src/assets/icons/factor-totp.svg | 3 + .../src/assets/icons/factor-webauthn.svg | 19 + .../src/assets/icons/info-icon.svg | 14 + .../src/assets/icons/loading-icon.svg | 15 + .../src/assets/icons/loading-ring.svg | 3 + .../src/assets/icons/lock.svg | 4 + .../src/assets/icons/logto-logo-dark.svg | 22 + .../src/assets/icons/logto-logo-light.svg | 21 + .../src/assets/icons/logto-logo-shadow.svg | 15 + .../src/assets/icons/more-social-icon.svg | 3 + .../src/assets/icons/nav-close.svg | 3 + .../src/assets/icons/organization-icon.svg | 4 + .../src/assets/icons/password-hide-icon.svg | 3 + .../src/assets/icons/password-show-icon.svg | 3 + .../src/assets/icons/search-icon.svg | 3 + .../src/assets/icons/switch-icon.svg | 3 + .../experience-legacy/src/assets/index.d.ts | 6 + .../BrandingHeader/index.module.scss | 56 ++ .../components/BrandingHeader/index.test.tsx | 17 + .../src/components/BrandingHeader/index.tsx | 57 ++ .../components/Button/IconButton.module.scss | 29 + .../src/components/Button/IconButton.tsx | 17 + .../Button/MfaFactorButton.module.scss | 39 + .../src/components/Button/MfaFactorButton.tsx | 73 ++ .../Button/RotatingRingIcon.module.scss | 14 + .../components/Button/RotatingRingIcon.tsx | 7 + .../Button/SocialLinkButton.module.scss | 49 ++ .../components/Button/SocialLinkButton.tsx | 73 ++ .../src/components/Button/index.module.scss | 113 +++ .../src/components/Button/index.test.tsx | 47 ++ .../src/components/Button/index.tsx | 72 ++ .../src/components/Checkbox/index.module.scss | 56 ++ .../src/components/Checkbox/index.tsx | 22 + .../src/components/ConfirmModal/AcModal.tsx | 85 ++ .../ConfirmModal/Acmodal.module.scss | 67 ++ .../ConfirmModal/MobileModal.module.scss | 76 ++ .../components/ConfirmModal/MobileModal.tsx | 57 ++ .../src/components/ConfirmModal/index.tsx | 4 + .../src/components/ConfirmModal/type.ts | 16 + .../src/components/Divider/index.module.scss | 24 + .../src/components/Divider/index.test.tsx | 10 + .../src/components/Divider/index.tsx | 29 + .../src/components/DynamicT/index.test.tsx | 26 + .../src/components/DynamicT/index.tsx | 31 + .../components/ErrorMessage/index.module.scss | 10 + .../src/components/ErrorMessage/index.tsx | 47 ++ .../src/components/GoogleOneTap/index.tsx | 43 ++ .../IdentifierRegisterForm/index.module.scss | 29 + .../IdentifierRegisterForm/index.test.tsx | 426 ++++++++++ .../IdentifierRegisterForm/index.tsx | 174 +++++ .../IdentifierRegisterForm/use-on-submit.ts | 62 ++ .../use-register-with-username.ts | 46 ++ .../IdentifierSignInForm/index.module.scss | 29 + .../IdentifierSignInForm/index.test.tsx | 293 +++++++ .../components/IdentifierSignInForm/index.tsx | 171 ++++ .../IdentifierSignInForm/use-on-submit.ts | 84 ++ .../NotchedBorder/index.module.scss | 131 ++++ .../InputField/NotchedBorder/index.tsx | 54 ++ .../InputFields/InputField/index.module.scss | 141 ++++ .../InputFields/InputField/index.test.tsx | 43 ++ .../InputFields/InputField/index.tsx | 147 ++++ .../PasswordInputField/index.test.tsx | 59 ++ .../InputFields/PasswordInputField/index.tsx | 41 + .../AnimatedPrefix/index.module.scss | 7 + .../SmartInputField/AnimatedPrefix/index.tsx | 56 ++ .../CountryCodeDropdown/index.module.scss | 139 ++++ .../CountryCodeDropdown/index.test.tsx | 148 ++++ .../CountryCodeDropdown/index.tsx | 281 +++++++ .../CountryCodeSelector/index.module.scss | 49 ++ .../CountryCodeSelector/index.tsx | 65 ++ .../SmartInputField/index.test.tsx | 279 +++++++ .../InputFields/SmartInputField/index.tsx | 101 +++ .../SmartInputField/use-smart-input-field.ts | 96 +++ .../InputFields/SmartInputField/utils.test.ts | 54 ++ .../InputFields/SmartInputField/utils.ts | 96 +++ .../src/components/InputFields/index.tsx | 3 + .../components/LoadingLayer/LoadingIcon.tsx | 15 + .../components/LoadingLayer/index.module.scss | 28 + .../src/components/LoadingLayer/index.tsx | 16 + .../components/LoadingMask/index.module.scss | 8 + .../src/components/LoadingMask/index.tsx | 13 + .../LogtoSignature/index.module.scss | 45 ++ .../src/components/LogtoSignature/index.tsx | 41 + .../src/components/NavBar/index.module.scss | 70 ++ .../src/components/NavBar/index.tsx | 70 ++ .../AppNotification/index.module.scss | 29 + .../Notification/AppNotification/index.tsx | 31 + .../InlineNotification/index.module.scss | 10 + .../Notification/InlineNotification/index.tsx | 21 + .../src/components/Notification/index.tsx | 2 + .../src/components/PageMeta/index.tsx | 17 + .../PasswordSignInForm/index.module.scss | 44 ++ .../PasswordSignInForm/index.test.tsx | 309 ++++++++ .../components/PasswordSignInForm/index.tsx | 188 +++++ .../SingleSignOnForm/index.module.scss | 23 + .../src/components/SingleSignOnForm/index.tsx | 121 +++ .../components/SwitchMfaFactorsLink/index.tsx | 35 + .../components/TermsLinks/index.module.scss | 24 + .../src/components/TermsLinks/index.tsx | 50 ++ .../src/components/TextLink/index.module.scss | 47 ++ .../src/components/TextLink/index.test.tsx | 15 + .../src/components/TextLink/index.tsx | 76 ++ .../src/components/Toast/index.module.scss | 53 ++ .../src/components/Toast/index.test.tsx | 35 + .../src/components/Toast/index.tsx | 47 ++ .../VerificationCode/index.module.scss | 46 ++ .../VerificationCode/index.test.tsx | 215 ++++++ .../src/components/VerificationCode/index.tsx | 221 ++++++ .../experience-legacy/src/constants/env.ts | 9 + .../DevelopmentTenantNotification/index.tsx | 99 +++ .../containers/ForgotPasswordLink/index.tsx | 35 + .../MfaFactorList/index.module.scss | 6 + .../src/containers/MfaFactorList/index.tsx | 55 ++ .../SetPassword/HiddenIdentifierInput.tsx | 25 + .../src/containers/SetPassword/Lite.test.tsx | 57 ++ .../src/containers/SetPassword/Lite.tsx | 85 ++ .../SetPassword/SetPassword.test.tsx | 104 +++ .../containers/SetPassword/SetPassword.tsx | 134 ++++ .../containers/SetPassword/TogglePassword.tsx | 41 + .../containers/SetPassword/index.module.scss | 33 + .../src/containers/SetPassword/index.tsx | 26 + .../SocialLanding/index.module.scss | 22 + .../src/containers/SocialLanding/index.tsx | 32 + .../SocialLinkAccount/index.module.scss | 33 + .../SocialLinkAccount/index.test.tsx | 122 +++ .../containers/SocialLinkAccount/index.tsx | 83 ++ .../use-social-link-related-user.ts | 34 + .../SocialSignInList/index.module.scss | 13 + .../SocialSignInList/index.test.tsx | 16 + .../src/containers/SocialSignInList/index.tsx | 52 ++ .../containers/SocialSignInList/use-social.ts | 103 +++ .../TermsAndPrivacyCheckbox/index.module.scss | 30 + .../TermsAndPrivacyCheckbox/index.tsx | 60 ++ .../TermsAndPrivacyCheckbox/intext.test.tsx | 21 + .../index.tsx | 34 + .../containers/TermsAndPrivacyLinks/index.tsx | 46 ++ .../TotpCodeVerification/index.module.scss | 9 + .../containers/TotpCodeVerification/index.tsx | 83 ++ .../use-totp-code-verification.ts | 38 + .../VerificationCode/PasswordSignInLink.tsx | 21 + .../VerificationCode/index.module.scss | 34 + .../VerificationCode/index.test.tsx | 366 +++++++++ .../src/containers/VerificationCode/index.tsx | 135 ++++ .../use-continue-flow-code-verification.ts | 100 +++ ...-forgot-password-flow-code-verification.ts | 65 ++ ...general-verification-code-error-handler.ts | 30 + .../use-identifier-error-alert.ts | 48 ++ .../use-link-social-confirm-modal.ts | 44 ++ .../use-register-flow-code-verification.ts | 134 ++++ .../use-resend-verification-code.ts | 59 ++ .../use-sign-in-flow-code-verification.ts | 135 ++++ .../src/containers/VerificationCode/utils.ts | 15 + packages/experience-legacy/src/favicon.ico | Bin 0 -> 15406 bytes .../experience-legacy/src/hooks/use-api.ts | 37 + .../src/hooks/use-check-single-sign-on.ts | 105 +++ .../src/hooks/use-confirm-modal.ts | 50 ++ .../src/hooks/use-connectors.ts | 63 ++ .../src/hooks/use-debounce.ts | 44 ++ .../src/hooks/use-error-handler.ts | 60 ++ .../src/hooks/use-global-redirect-to.ts | 82 ++ .../src/hooks/use-identifier-params.test.ts | 55 ++ .../src/hooks/use-identifier-params.ts | 39 + .../src/hooks/use-login-hint.ts | 10 + .../src/hooks/use-mfa-error-handler.ts | 150 ++++ .../src/hooks/use-mfa-factors-state.ts | 13 + .../src/hooks/use-native-message-listener.ts | 49 ++ .../src/hooks/use-password-action.ts | 78 ++ .../src/hooks/use-password-error-message.ts | 75 ++ .../src/hooks/use-password-sign-in.ts | 69 ++ .../src/hooks/use-platform.ts | 11 + .../hooks/use-pre-sign-in-error-handler.ts | 26 + .../src/hooks/use-prefilled-identifier.ts | 89 +++ .../use-required-profile-error-handler.ts | 74 ++ .../src/hooks/use-send-mfa-payload.ts | 60 ++ .../src/hooks/use-send-verification-code.ts | 68 ++ .../src/hooks/use-session-storages.test.ts | 39 + .../src/hooks/use-session-storages.ts | 75 ++ .../experience-legacy/src/hooks/use-sie.ts | 87 +++ .../src/hooks/use-single-sign-on-watch.ts | 139 ++++ .../src/hooks/use-single-sign-on.ts | 76 ++ .../src/hooks/use-skip-mfa.ts | 30 + .../src/hooks/use-social-link-account.ts | 32 + .../src/hooks/use-social-register.ts | 35 + .../src/hooks/use-start-totp-binding.ts | 45 ++ .../hooks/use-start-webauthn-processing.ts | 50 ++ .../experience-legacy/src/hooks/use-terms.ts | 60 ++ .../src/hooks/use-text-handler.ts | 32 + .../experience-legacy/src/hooks/use-toast.ts | 11 + .../experience-legacy/src/hooks/use-toggle.ts | 13 + .../src/hooks/use-webauthn-operation.ts | 84 ++ packages/experience-legacy/src/i18n/init.ts | 39 + packages/experience-legacy/src/i18n/utils.ts | 81 ++ .../experience-legacy/src/include.d/dom.d.ts | 79 ++ .../src/include.d/global.d.ts | 23 + .../src/include.d/i18next.d.ts | 9 + .../src/include.d/react-router-dom.d.ts | 15 + .../src/include.d/vite-env.d.ts | 2 + packages/experience-legacy/src/index.tsx | 11 + packages/experience-legacy/src/jest.setup.ts | 24 + .../src/pages/Callback/index.module.scss | 6 + .../src/pages/Callback/index.tsx | 41 + .../Callback/use-social-callback-handler.ts | 50 ++ .../OrganizationItem/index.module.scss | 32 + .../OrganizationItem/index.tsx | 54 ++ .../index.module.scss | 67 ++ .../OrganizationSelectorModal/index.tsx | 98 +++ .../OrganizationSelector/index.module.scss | 43 ++ .../Consent/OrganizationSelector/index.tsx | 98 +++ .../Consent/ScopeGroup/index.module.scss | 53 ++ .../src/pages/Consent/ScopeGroup/index.tsx | 54 ++ .../Consent/ScopesListCard/index.module.scss | 72 ++ .../pages/Consent/ScopesListCard/index.tsx | 69 ++ .../Consent/UserProfile/index.module.scss | 26 + .../src/pages/Consent/UserProfile/index.tsx | 35 + .../src/pages/Consent/index.module.scss | 46 ++ .../src/pages/Consent/index.tsx | 155 ++++ .../src/pages/Consent/util.test.ts | 15 + .../src/pages/Consent/util.ts | 17 + .../IdentifierProfileForm/index.module.scss | 19 + .../Continue/IdentifierProfileForm/index.tsx | 124 +++ .../SocialIdentityNotification.tsx | 57 ++ .../SetEmailOrPhone/index.module.scss | 6 + .../Continue/SetEmailOrPhone/index.test.tsx | 101 +++ .../pages/Continue/SetEmailOrPhone/index.tsx | 91 +++ .../pages/Continue/SetPassword/index.test.tsx | 121 +++ .../src/pages/Continue/SetPassword/index.tsx | 77 ++ .../pages/Continue/SetUsername/index.test.tsx | 58 ++ .../src/pages/Continue/SetUsername/index.tsx | 49 ++ .../Continue/SetUsername/use-set-username.ts | 53 ++ .../src/pages/Continue/index.tsx | 36 + .../src/pages/DirectSignIn/index.test.tsx | 123 +++ .../src/pages/DirectSignIn/index.tsx | 46 ++ .../src/pages/ErrorPage/index.module.scss | 42 + .../src/pages/ErrorPage/index.test.tsx | 16 + .../src/pages/ErrorPage/index.tsx | 42 + .../ForgotPasswordForm/index.module.scss | 19 + .../ForgotPasswordForm/index.test.tsx | 100 +++ .../ForgotPasswordForm/index.tsx | 123 +++ .../src/pages/ForgotPassword/index.test.tsx | 133 ++++ .../src/pages/ForgotPassword/index.tsx | 38 + .../src/pages/IdentifierRegister/index.tsx | 56 ++ .../use-identifier-sign-up-methods.ts | 35 + .../src/pages/IdentifierSignIn/index.tsx | 70 ++ .../use-identifier-sign-in-methods.ts | 35 + .../BackupCodeBinding/index.module.scss | 36 + .../MfaBinding/BackupCodeBinding/index.tsx | 84 ++ .../SecretSection/index.module.scss | 37 + .../TotpBinding/SecretSection/index.tsx | 59 ++ .../TotpBinding/VerificationSection.tsx | 24 + .../MfaBinding/TotpBinding/index.module.scss | 12 + .../pages/MfaBinding/TotpBinding/index.tsx | 52 ++ .../WebAuthnBinding/index.module.scss | 5 + .../MfaBinding/WebAuthnBinding/index.tsx | 59 ++ .../src/pages/MfaBinding/index.tsx | 30 + .../BackupCodeVerification/index.module.scss | 9 + .../BackupCodeVerification/index.tsx | 72 ++ .../TotpVerification/index.module.scss | 5 + .../TotpVerification/index.tsx | 35 + .../WebAuthnVerification/index.module.scss | 5 + .../WebAuthnVerification/index.tsx | 58 ++ .../src/pages/MfaVerification/index.tsx | 22 + .../src/pages/Register/index.module.scss | 34 + .../src/pages/Register/index.test.tsx | 103 +++ .../src/pages/Register/index.tsx | 131 ++++ .../src/pages/RegisterPassword/index.test.tsx | 154 ++++ .../src/pages/RegisterPassword/index.tsx | 87 +++ .../src/pages/ResetPassword/index.test.tsx | 79 ++ .../src/pages/ResetPassword/index.tsx | 82 ++ .../src/pages/ResetPasswordLanding/index.tsx | 65 ++ .../use-reset-password-methods.ts | 41 + .../src/pages/SignIn/Main.tsx | 56 ++ .../src/pages/SignIn/index.module.scss | 38 + .../src/pages/SignIn/index.test.tsx | 142 ++++ .../src/pages/SignIn/index.tsx | 123 +++ .../PasswordForm/VerificationCodeLink.tsx | 43 ++ .../PasswordForm/index.test.tsx | 103 +++ .../SignInPassword/PasswordForm/index.tsx | 128 +++ .../pages/SignInPassword/index.module.scss | 42 + .../src/pages/SignInPassword/index.test.tsx | 100 +++ .../src/pages/SignInPassword/index.tsx | 55 ++ .../SingleSignOnConnectors/index.module.scss | 13 + .../pages/SingleSignOnConnectors/index.tsx | 69 ++ .../src/pages/SingleSignOnEmail/index.tsx | 15 + .../src/pages/SingleSignOnLanding/index.tsx | 39 + .../src/pages/SocialLanding/index.module.scss | 5 + .../src/pages/SocialLanding/index.test.tsx | 57 ++ .../src/pages/SocialLanding/index.tsx | 47 ++ .../use-social-landing-handler.ts | 40 + .../pages/SocialLinkAccount/index.test.tsx | 96 +++ .../src/pages/SocialLinkAccount/index.tsx | 57 ++ .../SocialSignInWebCallback/SingleSignOn.tsx | 25 + .../SocialSignInWebCallback/SocialSignIn.tsx | 18 + .../SocialSignInWebCallback/index.test.tsx | 158 ++++ .../pages/SocialSignInWebCallback/index.tsx | 27 + .../use-single-sign-on-listener.ts | 156 ++++ .../use-social-sign-in-listener.ts | 182 +++++ .../src/pages/Springboard/index.tsx | 32 + .../src/pages/VerificationCode/index.test.tsx | 52 ++ .../src/pages/VerificationCode/index.tsx | 69 ++ .../experience-legacy/src/scss/_colors.scss | 229 ++++++ .../experience-legacy/src/scss/_fonts.scss | 29 + .../src/scss/_underscore.scss | 60 ++ .../src/scss/modal.module.scss | 10 + .../src/scss/normalized.scss | 44 ++ packages/experience-legacy/src/types/guard.ts | 132 ++++ packages/experience-legacy/src/types/index.ts | 47 ++ packages/experience-legacy/src/utils/a11y.ts | 26 + .../experience-legacy/src/utils/consts.ts | 7 + .../experience-legacy/src/utils/cookies.ts | 8 + .../src/utils/country-code.test.ts | 92 +++ .../src/utils/country-code.ts | 116 +++ packages/experience-legacy/src/utils/form.ts | 105 +++ .../src/utils/format.test.ts | 13 + .../experience-legacy/src/utils/format.ts | 9 + .../experience-legacy/src/utils/index.test.ts | 30 + packages/experience-legacy/src/utils/index.ts | 46 ++ packages/experience-legacy/src/utils/logo.ts | 42 + .../experience-legacy/src/utils/native-sdk.ts | 14 + .../src/utils/search-parameters.ts | 42 + .../src/utils/sign-in-experience.test.ts | 23 + .../src/utils/sign-in-experience.ts | 70 ++ .../src/utils/social-connectors.test.ts | 155 ++++ .../src/utils/social-connectors.ts | 223 ++++++ .../experience-legacy/src/utils/webauthn.ts | 10 + packages/experience-legacy/tsconfig.json | 15 + packages/experience-legacy/vite.config.ts | 57 ++ pnpm-lock.yaml | 212 ++++- 400 files changed, 23627 insertions(+), 3 deletions(-) create mode 100644 packages/experience-legacy/.eslintrc.cjs create mode 100644 packages/experience-legacy/CHANGELOG.md create mode 100644 packages/experience-legacy/README.md create mode 100644 packages/experience-legacy/index.html create mode 100644 packages/experience-legacy/jest.config.ts create mode 100644 packages/experience-legacy/package.json create mode 100644 packages/experience-legacy/src/App.tsx create mode 100644 packages/experience-legacy/src/Layout/AppLayout/CustomContent.tsx create mode 100644 packages/experience-legacy/src/Layout/AppLayout/index.module.scss create mode 100644 packages/experience-legacy/src/Layout/AppLayout/index.tsx create mode 100644 packages/experience-legacy/src/Layout/FirstScreenLayout/index.module.scss create mode 100644 packages/experience-legacy/src/Layout/FirstScreenLayout/index.tsx create mode 100644 packages/experience-legacy/src/Layout/FocusedAuthPageLayout/index.module.scss create mode 100644 packages/experience-legacy/src/Layout/FocusedAuthPageLayout/index.tsx create mode 100644 packages/experience-legacy/src/Layout/LandingPageLayout/index.module.scss create mode 100644 packages/experience-legacy/src/Layout/LandingPageLayout/index.tsx create mode 100644 packages/experience-legacy/src/Layout/SecondaryPageLayout/index.module.scss create mode 100644 packages/experience-legacy/src/Layout/SecondaryPageLayout/index.tsx create mode 100644 packages/experience-legacy/src/Layout/SectionLayout/index.module.scss create mode 100644 packages/experience-legacy/src/Layout/SectionLayout/index.tsx create mode 100644 packages/experience-legacy/src/Layout/StaticPageLayout/index.module.scss create mode 100644 packages/experience-legacy/src/Layout/StaticPageLayout/index.tsx create mode 100644 packages/experience-legacy/src/Providers/AppBoundary/AppMeta.tsx create mode 100644 packages/experience-legacy/src/Providers/AppBoundary/index.module.scss create mode 100644 packages/experience-legacy/src/Providers/AppBoundary/index.tsx create mode 100644 packages/experience-legacy/src/Providers/AppBoundary/use-color-theme.ts create mode 100644 packages/experience-legacy/src/Providers/ConfirmModalProvider/index.tsx create mode 100644 packages/experience-legacy/src/Providers/ConfirmModalProvider/indext.test.tsx create mode 100644 packages/experience-legacy/src/Providers/IframeModalProvider/IframeModal/index.module.scss create mode 100644 packages/experience-legacy/src/Providers/IframeModalProvider/IframeModal/index.tsx create mode 100644 packages/experience-legacy/src/Providers/IframeModalProvider/index.tsx create mode 100644 packages/experience-legacy/src/Providers/LoadingLayerProvider/index.tsx create mode 100644 packages/experience-legacy/src/Providers/PageContextProvider/PageContext.tsx create mode 100644 packages/experience-legacy/src/Providers/PageContextProvider/index.tsx create mode 100644 packages/experience-legacy/src/Providers/SettingsProvider/index.tsx create mode 100644 packages/experience-legacy/src/Providers/SettingsProvider/use-preview.ts create mode 100644 packages/experience-legacy/src/Providers/SettingsProvider/use-sign-in-experience.ts create mode 100644 packages/experience-legacy/src/Providers/SettingsProvider/use-theme.ts create mode 100644 packages/experience-legacy/src/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext.tsx create mode 100644 packages/experience-legacy/src/Providers/SingleSignOnFormModeContextProvider/index.tsx create mode 100644 packages/experience-legacy/src/Providers/ToastProvider/index.tsx create mode 100644 packages/experience-legacy/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx create mode 100644 packages/experience-legacy/src/Providers/UserInteractionContextProvider/index.tsx create mode 100644 packages/experience-legacy/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx create mode 100644 packages/experience-legacy/src/__mocks__/RenderWithPageContext/index.tsx create mode 100644 packages/experience-legacy/src/__mocks__/logto.tsx create mode 100644 packages/experience-legacy/src/__mocks__/social-connectors.tsx create mode 100644 packages/experience-legacy/src/apis/api.ts create mode 100644 packages/experience-legacy/src/apis/consent.ts create mode 100644 packages/experience-legacy/src/apis/interaction.ts create mode 100644 packages/experience-legacy/src/apis/settings.ts create mode 100644 packages/experience-legacy/src/apis/single-sign-on.ts create mode 100644 packages/experience-legacy/src/apis/utils.ts create mode 100644 packages/experience-legacy/src/assets/apple-touch-icon.png create mode 100644 packages/experience-legacy/src/assets/favicon.png create mode 100644 packages/experience-legacy/src/assets/icons/arrow-down.svg create mode 100644 packages/experience-legacy/src/assets/icons/arrow-next.svg create mode 100644 packages/experience-legacy/src/assets/icons/arrow-prev.svg create mode 100644 packages/experience-legacy/src/assets/icons/check-mark.svg create mode 100644 packages/experience-legacy/src/assets/icons/checkbox-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/clear-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/close-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/connect-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/default-user-avatar.svg create mode 100644 packages/experience-legacy/src/assets/icons/dev-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/empty-state-dark.svg create mode 100644 packages/experience-legacy/src/assets/icons/empty-state.svg create mode 100644 packages/experience-legacy/src/assets/icons/expand-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/expandable-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/factor-backup-code.svg create mode 100644 packages/experience-legacy/src/assets/icons/factor-totp.svg create mode 100644 packages/experience-legacy/src/assets/icons/factor-webauthn.svg create mode 100644 packages/experience-legacy/src/assets/icons/info-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/loading-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/loading-ring.svg create mode 100644 packages/experience-legacy/src/assets/icons/lock.svg create mode 100644 packages/experience-legacy/src/assets/icons/logto-logo-dark.svg create mode 100644 packages/experience-legacy/src/assets/icons/logto-logo-light.svg create mode 100644 packages/experience-legacy/src/assets/icons/logto-logo-shadow.svg create mode 100644 packages/experience-legacy/src/assets/icons/more-social-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/nav-close.svg create mode 100644 packages/experience-legacy/src/assets/icons/organization-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/password-hide-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/password-show-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/search-icon.svg create mode 100644 packages/experience-legacy/src/assets/icons/switch-icon.svg create mode 100644 packages/experience-legacy/src/assets/index.d.ts create mode 100644 packages/experience-legacy/src/components/BrandingHeader/index.module.scss create mode 100644 packages/experience-legacy/src/components/BrandingHeader/index.test.tsx create mode 100644 packages/experience-legacy/src/components/BrandingHeader/index.tsx create mode 100644 packages/experience-legacy/src/components/Button/IconButton.module.scss create mode 100644 packages/experience-legacy/src/components/Button/IconButton.tsx create mode 100644 packages/experience-legacy/src/components/Button/MfaFactorButton.module.scss create mode 100644 packages/experience-legacy/src/components/Button/MfaFactorButton.tsx create mode 100644 packages/experience-legacy/src/components/Button/RotatingRingIcon.module.scss create mode 100644 packages/experience-legacy/src/components/Button/RotatingRingIcon.tsx create mode 100644 packages/experience-legacy/src/components/Button/SocialLinkButton.module.scss create mode 100644 packages/experience-legacy/src/components/Button/SocialLinkButton.tsx create mode 100644 packages/experience-legacy/src/components/Button/index.module.scss create mode 100644 packages/experience-legacy/src/components/Button/index.test.tsx create mode 100644 packages/experience-legacy/src/components/Button/index.tsx create mode 100644 packages/experience-legacy/src/components/Checkbox/index.module.scss create mode 100644 packages/experience-legacy/src/components/Checkbox/index.tsx create mode 100644 packages/experience-legacy/src/components/ConfirmModal/AcModal.tsx create mode 100644 packages/experience-legacy/src/components/ConfirmModal/Acmodal.module.scss create mode 100644 packages/experience-legacy/src/components/ConfirmModal/MobileModal.module.scss create mode 100644 packages/experience-legacy/src/components/ConfirmModal/MobileModal.tsx create mode 100644 packages/experience-legacy/src/components/ConfirmModal/index.tsx create mode 100644 packages/experience-legacy/src/components/ConfirmModal/type.ts create mode 100644 packages/experience-legacy/src/components/Divider/index.module.scss create mode 100644 packages/experience-legacy/src/components/Divider/index.test.tsx create mode 100644 packages/experience-legacy/src/components/Divider/index.tsx create mode 100644 packages/experience-legacy/src/components/DynamicT/index.test.tsx create mode 100644 packages/experience-legacy/src/components/DynamicT/index.tsx create mode 100644 packages/experience-legacy/src/components/ErrorMessage/index.module.scss create mode 100644 packages/experience-legacy/src/components/ErrorMessage/index.tsx create mode 100644 packages/experience-legacy/src/components/GoogleOneTap/index.tsx create mode 100644 packages/experience-legacy/src/components/IdentifierRegisterForm/index.module.scss create mode 100644 packages/experience-legacy/src/components/IdentifierRegisterForm/index.test.tsx create mode 100644 packages/experience-legacy/src/components/IdentifierRegisterForm/index.tsx create mode 100644 packages/experience-legacy/src/components/IdentifierRegisterForm/use-on-submit.ts create mode 100644 packages/experience-legacy/src/components/IdentifierRegisterForm/use-register-with-username.ts create mode 100644 packages/experience-legacy/src/components/IdentifierSignInForm/index.module.scss create mode 100644 packages/experience-legacy/src/components/IdentifierSignInForm/index.test.tsx create mode 100644 packages/experience-legacy/src/components/IdentifierSignInForm/index.tsx create mode 100644 packages/experience-legacy/src/components/IdentifierSignInForm/use-on-submit.ts create mode 100644 packages/experience-legacy/src/components/InputFields/InputField/NotchedBorder/index.module.scss create mode 100644 packages/experience-legacy/src/components/InputFields/InputField/NotchedBorder/index.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/InputField/index.module.scss create mode 100644 packages/experience-legacy/src/components/InputFields/InputField/index.test.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/InputField/index.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/PasswordInputField/index.test.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/PasswordInputField/index.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/AnimatedPrefix/index.module.scss create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/AnimatedPrefix/index.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.module.scss create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.test.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/CountryCodeSelector/CountryCodeDropdown/index.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/CountryCodeSelector/index.module.scss create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/CountryCodeSelector/index.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/index.test.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/index.tsx create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/use-smart-input-field.ts create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/utils.test.ts create mode 100644 packages/experience-legacy/src/components/InputFields/SmartInputField/utils.ts create mode 100644 packages/experience-legacy/src/components/InputFields/index.tsx create mode 100644 packages/experience-legacy/src/components/LoadingLayer/LoadingIcon.tsx create mode 100644 packages/experience-legacy/src/components/LoadingLayer/index.module.scss create mode 100644 packages/experience-legacy/src/components/LoadingLayer/index.tsx create mode 100644 packages/experience-legacy/src/components/LoadingMask/index.module.scss create mode 100644 packages/experience-legacy/src/components/LoadingMask/index.tsx create mode 100644 packages/experience-legacy/src/components/LogtoSignature/index.module.scss create mode 100644 packages/experience-legacy/src/components/LogtoSignature/index.tsx create mode 100644 packages/experience-legacy/src/components/NavBar/index.module.scss create mode 100644 packages/experience-legacy/src/components/NavBar/index.tsx create mode 100644 packages/experience-legacy/src/components/Notification/AppNotification/index.module.scss create mode 100644 packages/experience-legacy/src/components/Notification/AppNotification/index.tsx create mode 100644 packages/experience-legacy/src/components/Notification/InlineNotification/index.module.scss create mode 100644 packages/experience-legacy/src/components/Notification/InlineNotification/index.tsx create mode 100644 packages/experience-legacy/src/components/Notification/index.tsx create mode 100644 packages/experience-legacy/src/components/PageMeta/index.tsx create mode 100644 packages/experience-legacy/src/components/PasswordSignInForm/index.module.scss create mode 100644 packages/experience-legacy/src/components/PasswordSignInForm/index.test.tsx create mode 100644 packages/experience-legacy/src/components/PasswordSignInForm/index.tsx create mode 100644 packages/experience-legacy/src/components/SingleSignOnForm/index.module.scss create mode 100644 packages/experience-legacy/src/components/SingleSignOnForm/index.tsx create mode 100644 packages/experience-legacy/src/components/SwitchMfaFactorsLink/index.tsx create mode 100644 packages/experience-legacy/src/components/TermsLinks/index.module.scss create mode 100644 packages/experience-legacy/src/components/TermsLinks/index.tsx create mode 100644 packages/experience-legacy/src/components/TextLink/index.module.scss create mode 100644 packages/experience-legacy/src/components/TextLink/index.test.tsx create mode 100644 packages/experience-legacy/src/components/TextLink/index.tsx create mode 100644 packages/experience-legacy/src/components/Toast/index.module.scss create mode 100644 packages/experience-legacy/src/components/Toast/index.test.tsx create mode 100644 packages/experience-legacy/src/components/Toast/index.tsx create mode 100644 packages/experience-legacy/src/components/VerificationCode/index.module.scss create mode 100644 packages/experience-legacy/src/components/VerificationCode/index.test.tsx create mode 100644 packages/experience-legacy/src/components/VerificationCode/index.tsx create mode 100644 packages/experience-legacy/src/constants/env.ts create mode 100644 packages/experience-legacy/src/containers/DevelopmentTenantNotification/index.tsx create mode 100644 packages/experience-legacy/src/containers/ForgotPasswordLink/index.tsx create mode 100644 packages/experience-legacy/src/containers/MfaFactorList/index.module.scss create mode 100644 packages/experience-legacy/src/containers/MfaFactorList/index.tsx create mode 100644 packages/experience-legacy/src/containers/SetPassword/HiddenIdentifierInput.tsx create mode 100644 packages/experience-legacy/src/containers/SetPassword/Lite.test.tsx create mode 100644 packages/experience-legacy/src/containers/SetPassword/Lite.tsx create mode 100644 packages/experience-legacy/src/containers/SetPassword/SetPassword.test.tsx create mode 100644 packages/experience-legacy/src/containers/SetPassword/SetPassword.tsx create mode 100644 packages/experience-legacy/src/containers/SetPassword/TogglePassword.tsx create mode 100644 packages/experience-legacy/src/containers/SetPassword/index.module.scss create mode 100644 packages/experience-legacy/src/containers/SetPassword/index.tsx create mode 100644 packages/experience-legacy/src/containers/SocialLanding/index.module.scss create mode 100644 packages/experience-legacy/src/containers/SocialLanding/index.tsx create mode 100644 packages/experience-legacy/src/containers/SocialLinkAccount/index.module.scss create mode 100644 packages/experience-legacy/src/containers/SocialLinkAccount/index.test.tsx create mode 100644 packages/experience-legacy/src/containers/SocialLinkAccount/index.tsx create mode 100644 packages/experience-legacy/src/containers/SocialLinkAccount/use-social-link-related-user.ts create mode 100644 packages/experience-legacy/src/containers/SocialSignInList/index.module.scss create mode 100644 packages/experience-legacy/src/containers/SocialSignInList/index.test.tsx create mode 100644 packages/experience-legacy/src/containers/SocialSignInList/index.tsx create mode 100644 packages/experience-legacy/src/containers/SocialSignInList/use-social.ts create mode 100644 packages/experience-legacy/src/containers/TermsAndPrivacyCheckbox/index.module.scss create mode 100644 packages/experience-legacy/src/containers/TermsAndPrivacyCheckbox/index.tsx create mode 100644 packages/experience-legacy/src/containers/TermsAndPrivacyCheckbox/intext.test.tsx create mode 100644 packages/experience-legacy/src/containers/TermsAndPrivacyConfirmModalContent/index.tsx create mode 100644 packages/experience-legacy/src/containers/TermsAndPrivacyLinks/index.tsx create mode 100644 packages/experience-legacy/src/containers/TotpCodeVerification/index.module.scss create mode 100644 packages/experience-legacy/src/containers/TotpCodeVerification/index.tsx create mode 100644 packages/experience-legacy/src/containers/TotpCodeVerification/use-totp-code-verification.ts create mode 100644 packages/experience-legacy/src/containers/VerificationCode/PasswordSignInLink.tsx create mode 100644 packages/experience-legacy/src/containers/VerificationCode/index.module.scss create mode 100644 packages/experience-legacy/src/containers/VerificationCode/index.test.tsx create mode 100644 packages/experience-legacy/src/containers/VerificationCode/index.tsx create mode 100644 packages/experience-legacy/src/containers/VerificationCode/use-continue-flow-code-verification.ts create mode 100644 packages/experience-legacy/src/containers/VerificationCode/use-forgot-password-flow-code-verification.ts create mode 100644 packages/experience-legacy/src/containers/VerificationCode/use-general-verification-code-error-handler.ts create mode 100644 packages/experience-legacy/src/containers/VerificationCode/use-identifier-error-alert.ts create mode 100644 packages/experience-legacy/src/containers/VerificationCode/use-link-social-confirm-modal.ts create mode 100644 packages/experience-legacy/src/containers/VerificationCode/use-register-flow-code-verification.ts create mode 100644 packages/experience-legacy/src/containers/VerificationCode/use-resend-verification-code.ts create mode 100644 packages/experience-legacy/src/containers/VerificationCode/use-sign-in-flow-code-verification.ts create mode 100644 packages/experience-legacy/src/containers/VerificationCode/utils.ts create mode 100644 packages/experience-legacy/src/favicon.ico create mode 100644 packages/experience-legacy/src/hooks/use-api.ts create mode 100644 packages/experience-legacy/src/hooks/use-check-single-sign-on.ts create mode 100644 packages/experience-legacy/src/hooks/use-confirm-modal.ts create mode 100644 packages/experience-legacy/src/hooks/use-connectors.ts create mode 100644 packages/experience-legacy/src/hooks/use-debounce.ts create mode 100644 packages/experience-legacy/src/hooks/use-error-handler.ts create mode 100644 packages/experience-legacy/src/hooks/use-global-redirect-to.ts create mode 100644 packages/experience-legacy/src/hooks/use-identifier-params.test.ts create mode 100644 packages/experience-legacy/src/hooks/use-identifier-params.ts create mode 100644 packages/experience-legacy/src/hooks/use-login-hint.ts create mode 100644 packages/experience-legacy/src/hooks/use-mfa-error-handler.ts create mode 100644 packages/experience-legacy/src/hooks/use-mfa-factors-state.ts create mode 100644 packages/experience-legacy/src/hooks/use-native-message-listener.ts create mode 100644 packages/experience-legacy/src/hooks/use-password-action.ts create mode 100644 packages/experience-legacy/src/hooks/use-password-error-message.ts create mode 100644 packages/experience-legacy/src/hooks/use-password-sign-in.ts create mode 100644 packages/experience-legacy/src/hooks/use-platform.ts create mode 100644 packages/experience-legacy/src/hooks/use-pre-sign-in-error-handler.ts create mode 100644 packages/experience-legacy/src/hooks/use-prefilled-identifier.ts create mode 100644 packages/experience-legacy/src/hooks/use-required-profile-error-handler.ts create mode 100644 packages/experience-legacy/src/hooks/use-send-mfa-payload.ts create mode 100644 packages/experience-legacy/src/hooks/use-send-verification-code.ts create mode 100644 packages/experience-legacy/src/hooks/use-session-storages.test.ts create mode 100644 packages/experience-legacy/src/hooks/use-session-storages.ts create mode 100644 packages/experience-legacy/src/hooks/use-sie.ts create mode 100644 packages/experience-legacy/src/hooks/use-single-sign-on-watch.ts create mode 100644 packages/experience-legacy/src/hooks/use-single-sign-on.ts create mode 100644 packages/experience-legacy/src/hooks/use-skip-mfa.ts create mode 100644 packages/experience-legacy/src/hooks/use-social-link-account.ts create mode 100644 packages/experience-legacy/src/hooks/use-social-register.ts create mode 100644 packages/experience-legacy/src/hooks/use-start-totp-binding.ts create mode 100644 packages/experience-legacy/src/hooks/use-start-webauthn-processing.ts create mode 100644 packages/experience-legacy/src/hooks/use-terms.ts create mode 100644 packages/experience-legacy/src/hooks/use-text-handler.ts create mode 100644 packages/experience-legacy/src/hooks/use-toast.ts create mode 100644 packages/experience-legacy/src/hooks/use-toggle.ts create mode 100644 packages/experience-legacy/src/hooks/use-webauthn-operation.ts create mode 100644 packages/experience-legacy/src/i18n/init.ts create mode 100644 packages/experience-legacy/src/i18n/utils.ts create mode 100644 packages/experience-legacy/src/include.d/dom.d.ts create mode 100644 packages/experience-legacy/src/include.d/global.d.ts create mode 100644 packages/experience-legacy/src/include.d/i18next.d.ts create mode 100644 packages/experience-legacy/src/include.d/react-router-dom.d.ts create mode 100644 packages/experience-legacy/src/include.d/vite-env.d.ts create mode 100644 packages/experience-legacy/src/index.tsx create mode 100644 packages/experience-legacy/src/jest.setup.ts create mode 100644 packages/experience-legacy/src/pages/Callback/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Callback/index.tsx create mode 100644 packages/experience-legacy/src/pages/Callback/use-social-callback-handler.ts create mode 100644 packages/experience-legacy/src/pages/Consent/OrganizationSelector/OrganizationItem/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Consent/OrganizationSelector/OrganizationItem/index.tsx create mode 100644 packages/experience-legacy/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.tsx create mode 100644 packages/experience-legacy/src/pages/Consent/OrganizationSelector/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Consent/OrganizationSelector/index.tsx create mode 100644 packages/experience-legacy/src/pages/Consent/ScopeGroup/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Consent/ScopeGroup/index.tsx create mode 100644 packages/experience-legacy/src/pages/Consent/ScopesListCard/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Consent/ScopesListCard/index.tsx create mode 100644 packages/experience-legacy/src/pages/Consent/UserProfile/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Consent/UserProfile/index.tsx create mode 100644 packages/experience-legacy/src/pages/Consent/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Consent/index.tsx create mode 100644 packages/experience-legacy/src/pages/Consent/util.test.ts create mode 100644 packages/experience-legacy/src/pages/Consent/util.ts create mode 100644 packages/experience-legacy/src/pages/Continue/IdentifierProfileForm/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Continue/IdentifierProfileForm/index.tsx create mode 100644 packages/experience-legacy/src/pages/Continue/SetEmailOrPhone/SocialIdentityNotification.tsx create mode 100644 packages/experience-legacy/src/pages/Continue/SetEmailOrPhone/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Continue/SetEmailOrPhone/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/Continue/SetEmailOrPhone/index.tsx create mode 100644 packages/experience-legacy/src/pages/Continue/SetPassword/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/Continue/SetPassword/index.tsx create mode 100644 packages/experience-legacy/src/pages/Continue/SetUsername/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/Continue/SetUsername/index.tsx create mode 100644 packages/experience-legacy/src/pages/Continue/SetUsername/use-set-username.ts create mode 100644 packages/experience-legacy/src/pages/Continue/index.tsx create mode 100644 packages/experience-legacy/src/pages/DirectSignIn/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/DirectSignIn/index.tsx create mode 100644 packages/experience-legacy/src/pages/ErrorPage/index.module.scss create mode 100644 packages/experience-legacy/src/pages/ErrorPage/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/ErrorPage/index.tsx create mode 100644 packages/experience-legacy/src/pages/ForgotPassword/ForgotPasswordForm/index.module.scss create mode 100644 packages/experience-legacy/src/pages/ForgotPassword/ForgotPasswordForm/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx create mode 100644 packages/experience-legacy/src/pages/ForgotPassword/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/ForgotPassword/index.tsx create mode 100644 packages/experience-legacy/src/pages/IdentifierRegister/index.tsx create mode 100644 packages/experience-legacy/src/pages/IdentifierRegister/use-identifier-sign-up-methods.ts create mode 100644 packages/experience-legacy/src/pages/IdentifierSignIn/index.tsx create mode 100644 packages/experience-legacy/src/pages/IdentifierSignIn/use-identifier-sign-in-methods.ts create mode 100644 packages/experience-legacy/src/pages/MfaBinding/BackupCodeBinding/index.module.scss create mode 100644 packages/experience-legacy/src/pages/MfaBinding/BackupCodeBinding/index.tsx create mode 100644 packages/experience-legacy/src/pages/MfaBinding/TotpBinding/SecretSection/index.module.scss create mode 100644 packages/experience-legacy/src/pages/MfaBinding/TotpBinding/SecretSection/index.tsx create mode 100644 packages/experience-legacy/src/pages/MfaBinding/TotpBinding/VerificationSection.tsx create mode 100644 packages/experience-legacy/src/pages/MfaBinding/TotpBinding/index.module.scss create mode 100644 packages/experience-legacy/src/pages/MfaBinding/TotpBinding/index.tsx create mode 100644 packages/experience-legacy/src/pages/MfaBinding/WebAuthnBinding/index.module.scss create mode 100644 packages/experience-legacy/src/pages/MfaBinding/WebAuthnBinding/index.tsx create mode 100644 packages/experience-legacy/src/pages/MfaBinding/index.tsx create mode 100644 packages/experience-legacy/src/pages/MfaVerification/BackupCodeVerification/index.module.scss create mode 100644 packages/experience-legacy/src/pages/MfaVerification/BackupCodeVerification/index.tsx create mode 100644 packages/experience-legacy/src/pages/MfaVerification/TotpVerification/index.module.scss create mode 100644 packages/experience-legacy/src/pages/MfaVerification/TotpVerification/index.tsx create mode 100644 packages/experience-legacy/src/pages/MfaVerification/WebAuthnVerification/index.module.scss create mode 100644 packages/experience-legacy/src/pages/MfaVerification/WebAuthnVerification/index.tsx create mode 100644 packages/experience-legacy/src/pages/MfaVerification/index.tsx create mode 100644 packages/experience-legacy/src/pages/Register/index.module.scss create mode 100644 packages/experience-legacy/src/pages/Register/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/Register/index.tsx create mode 100644 packages/experience-legacy/src/pages/RegisterPassword/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/RegisterPassword/index.tsx create mode 100644 packages/experience-legacy/src/pages/ResetPassword/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/ResetPassword/index.tsx create mode 100644 packages/experience-legacy/src/pages/ResetPasswordLanding/index.tsx create mode 100644 packages/experience-legacy/src/pages/ResetPasswordLanding/use-reset-password-methods.ts create mode 100644 packages/experience-legacy/src/pages/SignIn/Main.tsx create mode 100644 packages/experience-legacy/src/pages/SignIn/index.module.scss create mode 100644 packages/experience-legacy/src/pages/SignIn/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/SignIn/index.tsx create mode 100644 packages/experience-legacy/src/pages/SignInPassword/PasswordForm/VerificationCodeLink.tsx create mode 100644 packages/experience-legacy/src/pages/SignInPassword/PasswordForm/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/SignInPassword/PasswordForm/index.tsx create mode 100644 packages/experience-legacy/src/pages/SignInPassword/index.module.scss create mode 100644 packages/experience-legacy/src/pages/SignInPassword/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/SignInPassword/index.tsx create mode 100644 packages/experience-legacy/src/pages/SingleSignOnConnectors/index.module.scss create mode 100644 packages/experience-legacy/src/pages/SingleSignOnConnectors/index.tsx create mode 100644 packages/experience-legacy/src/pages/SingleSignOnEmail/index.tsx create mode 100644 packages/experience-legacy/src/pages/SingleSignOnLanding/index.tsx create mode 100644 packages/experience-legacy/src/pages/SocialLanding/index.module.scss create mode 100644 packages/experience-legacy/src/pages/SocialLanding/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/SocialLanding/index.tsx create mode 100644 packages/experience-legacy/src/pages/SocialLanding/use-social-landing-handler.ts create mode 100644 packages/experience-legacy/src/pages/SocialLinkAccount/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/SocialLinkAccount/index.tsx create mode 100644 packages/experience-legacy/src/pages/SocialSignInWebCallback/SingleSignOn.tsx create mode 100644 packages/experience-legacy/src/pages/SocialSignInWebCallback/SocialSignIn.tsx create mode 100644 packages/experience-legacy/src/pages/SocialSignInWebCallback/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/SocialSignInWebCallback/index.tsx create mode 100644 packages/experience-legacy/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts create mode 100644 packages/experience-legacy/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts create mode 100644 packages/experience-legacy/src/pages/Springboard/index.tsx create mode 100644 packages/experience-legacy/src/pages/VerificationCode/index.test.tsx create mode 100644 packages/experience-legacy/src/pages/VerificationCode/index.tsx create mode 100644 packages/experience-legacy/src/scss/_colors.scss create mode 100644 packages/experience-legacy/src/scss/_fonts.scss create mode 100644 packages/experience-legacy/src/scss/_underscore.scss create mode 100644 packages/experience-legacy/src/scss/modal.module.scss create mode 100644 packages/experience-legacy/src/scss/normalized.scss create mode 100644 packages/experience-legacy/src/types/guard.ts create mode 100644 packages/experience-legacy/src/types/index.ts create mode 100644 packages/experience-legacy/src/utils/a11y.ts create mode 100644 packages/experience-legacy/src/utils/consts.ts create mode 100644 packages/experience-legacy/src/utils/cookies.ts create mode 100644 packages/experience-legacy/src/utils/country-code.test.ts create mode 100644 packages/experience-legacy/src/utils/country-code.ts create mode 100644 packages/experience-legacy/src/utils/form.ts create mode 100644 packages/experience-legacy/src/utils/format.test.ts create mode 100644 packages/experience-legacy/src/utils/format.ts create mode 100644 packages/experience-legacy/src/utils/index.test.ts create mode 100644 packages/experience-legacy/src/utils/index.ts create mode 100644 packages/experience-legacy/src/utils/logo.ts create mode 100644 packages/experience-legacy/src/utils/native-sdk.ts create mode 100644 packages/experience-legacy/src/utils/search-parameters.ts create mode 100644 packages/experience-legacy/src/utils/sign-in-experience.test.ts create mode 100644 packages/experience-legacy/src/utils/sign-in-experience.ts create mode 100644 packages/experience-legacy/src/utils/social-connectors.test.ts create mode 100644 packages/experience-legacy/src/utils/social-connectors.ts create mode 100644 packages/experience-legacy/src/utils/webauthn.ts create mode 100644 packages/experience-legacy/tsconfig.json create mode 100644 packages/experience-legacy/vite.config.ts diff --git a/package.json b/package.json index 575385f84cd..126199dfdbc 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "prepack": "pnpm -r prepack", "dev": "pnpm -r prepack && pnpm start:dev", "dev:cloud": "IS_CLOUD=1 CONSOLE_PUBLIC_URL=/ pnpm dev", - "start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" dev", + "start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests --filter=!@logto/experience-legacy --filter \"!./packages/connectors/connector-*\" dev", "start": "cd packages/core && NODE_ENV=production node .", "cli": "logto", "translate": "logto-translate", diff --git a/packages/core/package.json b/packages/core/package.json index b15c22f037b..8abf8303958 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,6 +39,7 @@ "@logto/core-kit": "workspace:^2.5.0", "@logto/demo-app": "workspace:*", "@logto/experience": "workspace:*", + "@logto/experience-legacy": "workspace:*", "@logto/language-kit": "workspace:^1.1.0", "@logto/phrases": "workspace:^1.13.0", "@logto/phrases-experience": "workspace:^1.7.0", diff --git a/packages/core/src/middleware/koa-spa-proxy.ts b/packages/core/src/middleware/koa-spa-proxy.ts index 9ad11eb8f54..71b65058b25 100644 --- a/packages/core/src/middleware/koa-spa-proxy.ts +++ b/packages/core/src/middleware/koa-spa-proxy.ts @@ -22,7 +22,7 @@ type Properties = { export default function koaSpaProxy({ mountedApps, - packagePath = 'experience', + packagePath = EnvSet.values.isDevFeaturesEnabled ? 'experience' : 'experience-legacy', port = 5001, prefix = '', queries, diff --git a/packages/experience-legacy/.eslintrc.cjs b/packages/experience-legacy/.eslintrc.cjs new file mode 100644 index 00000000000..40bcfa69ac0 --- /dev/null +++ b/packages/experience-legacy/.eslintrc.cjs @@ -0,0 +1,22 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: '@silverhand/react', + rules: { + 'jsx-a11y/no-autofocus': 'off', + 'unicorn/prefer-string-replace-all': 'off', + }, + overrides: [ + { + files: ['*.config.js', '*.config.ts', '*.d.ts'], + rules: { + 'import/no-unused-modules': 'off', + }, + }, + { + files: ['*.d.ts'], + rules: { + 'import/no-unassigned-import': 'off', + }, + }, + ], +}; diff --git a/packages/experience-legacy/CHANGELOG.md b/packages/experience-legacy/CHANGELOG.md new file mode 100644 index 00000000000..497cf1bc245 --- /dev/null +++ b/packages/experience-legacy/CHANGELOG.md @@ -0,0 +1,727 @@ +# Change Log + +## 1.8.0 + +### Minor Changes + +- 3a839f6d6: support organization logo and sign-in experience override + + Now it's able to set light and dark logos for organizations. You can upload the logos in the organization settings page. + + Also, it's possible to override the sign-in experience logo from an organization. Simply add the `organization_id` parameter to the authentication request. In most Logto SDKs, it can be done by using the `extraParams` field in the `signIn` method. + + For example, in the JavaScript SDK: + + ```ts + import LogtoClient from "@logto/client"; + + const logtoClient = new LogtoClient(/* your configuration */); + + logtoClient.signIn({ + redirectUri: "https://your-app.com/callback", + extraParams: { + organization_id: "", + }, + }); + ``` + + The value `` can be found in the organization settings page. + + If you could not find the `extraParams` field in the SDK you are using, please let us know. + +- 62f5e5e0c: support app-level branding + + You can now set logos, favicons, and colors for your app. These settings will be used in the sign-in experience when the app initiates the authentication flow. For apps that have no branding settings, the omni sign-in experience branding will be used. + + If `organization_id` is provided in the authentication request, the app-level branding settings will be overridden by the organization's branding settings, if available. + +- d203c8d2f: support experience data server-side rendering + + Logto now injects the sign-in experience settings and phrases into the `index.html` file for better first-screen performance. The experience app will still fetch the settings and phrases from the server if: + + - The server didn't inject the settings and phrases. + - The parameters in the URL are different from server-rendered data. + +- 3bf756f2b: use Vite for transpilation and bundling + + Removed ParcelJS and replaced with Vite. No breaking changes should be expected, but use a minor version bump to catch your attention. + + > [!Important] + > The browserlist configuration for `@logto/experience` and been synced with what is stated in README.md. + +- 62f5e5e0c: support dark favicon + + The favicon for the dark theme now can be set in the sign-in experience branding settings. + +## 1.7.0 + +### Minor Changes + +- 061a30a87: support agree to terms polices for Logto’s sign-in experiences + + - Automatic: Users automatically agree to terms by continuing to use the service + - ManualRegistrationOnly: Users must agree to terms by checking a box during registration, and don't need to agree when signing in + - Manual: Users must agree to terms by checking a box during registration or signing in + +- 50c35a214: support Google One Tap + + - Conditionally load Google One Tap script if it's enabled in the config. + - Support callback from Google One Tap. + +### Patch Changes + +- 136320584: allow skipping manual account linking during sign-in + + You can find this configuration in Console -> Sign-in experience -> Sign-up and sign-in -> Social sign-in -> Automatic account linking. + + When switched on, if a user signs in with a social identity that is new to the system, and there is exactly one existing account with the same identifier (e.g., email), Logto will automatically link the account with the social identity instead of prompting the user for account linking. + +## 1.6.2 + +### Patch Changes + +- cb1a38c40: show global loading icon on page relocate + + This is to address the issue where the user is redirected back to the client after a successful login, but the page is not yet fully loaded. This will show a global loading icon to indicate that the page is still loading. Preventing the user from interacting with the current sign-in page and avoid page idling confusion. + +## 1.6.1 + +### Patch Changes + +- b80934ac5: fix native social sign-in callback + + In a native environment, the social sign-in callback that posts to the native container (e.g. WKWebView in iOS) was wrong. + + This was introduced by a refactor in #5536: It updated the callback path from `/sign-in/social/:connectorId` to `/callback/social/:connectorId`. However, the function to post the message to the native container was not updated accordingly. + +- bbd399e15: fix the new user from SSO register hook event not triggering bug + + ### Issue + + When a new user registers via SSO, the `PostRegister` interaction hook event is not triggered. `PostSignIn` event is mistakenly triggered instead. + + ### Root Cause + + In the SSO `post /api/interaction/sso/:connectionId/registration` API, we update the interaction event to `Register`. + However, the hook middleware reads the event from interaction session ahead of the API logic, and the event is not updated resulting in the wrong event being triggered. + + In the current interaction API design, we should mutate the interaction event by calling the `PUT /api/interaction/event` API, instead of updating the event directly in the submit interaction APIs. (Just like the no direct mutation rule for a react state). So we can ensure the correct side effect like logs and hooks are triggered properly. + + All the other sign-in methods are using the `PUT /api/interaction/event` API to update the event. But when implementing the SSO registration API, we were trying to reduce the API requests and directly updated the event in the registration API which will submit the interaction directly. + + ### Solution + + Remove the event update logic in the SSO registration API and call the `PUT /api/interaction/event` API to update the event. + This will ensure the correct event is triggered in the hook middleware. + + ### Action Items + + Align the current interaction API design for now. + Need to improve the session/interaction API logic to simplify the whole process. + +## 1.6.0 + +### Minor Changes + +- 7756f50f8: support direct sign-in for sso +- 2cbc591ff: support direct sign-in + + Instead of showing a screen for the user to choose between the sign-in methods, a specific sign-in method can be initiated directly by setting the `direct_sign_in` parameter in the OIDC authentication request. + + This parameter follows the format of `direct_sign_in=:`, where: + + - `` is the sign-in method to trigger. Currently the only supported value is `social`. + - `` is the target value for the sign-in method. If the method is `social`, the value is the social connector's `target`. + + When a valid `direct_sign_in` parameter is set, the first screen will be skipped and the specified sign-in method will be triggered immediately upon entering the sign-in experience. If the parameter is invalid, the default behavior of showing the first screen will be used. + +### Patch Changes + +- 5a7204571: skip non-object messages in the native environment + + In the `WKWebView` of new iOS versions, some script will constantly post messages to the + window object with increasing numbers as the message content ("1", "2", "3", ...). + + Ideally, we should check the source of the message with Logto-specific identifier in the + `event.data`; however, this change will result a breaking change for the existing + native SDK implementations. Add the `isObject` check to prevent the crazy messages while + keeping the backward compatibility. + +## 1.5.0 + +### Minor Changes + +- 32df9acde: update user consent page to support the new third-party application feature + + - Only show the user consent page if current application is a third-party application, otherwise auto-consent the requested scopes. + - Add the new fetching API to get the user consent context. Including the application detail, authenticated user info, all the requested scopes and user organizations info (if requested scopes include the organization scope). + - Add the new user consent interaction API and authorize button. User have to manually authorize the requested scopes for the third-party application before continue the authentication flow. + +- 31e60811d: use Node 20 LTS for engine requirement. + + Note: We mark it as minor because Logto is shipping with Docker image and it's not a breaking change for users. + +### Patch Changes + +- 9089dbf84: upgrade TypeScript to 5.3.3 + +## 1.4.0 + +### Minor Changes + +- 9a7b19e49: Implement the new single sign-on (SSO) interaction flow + + - `/single-sign-on/email` - The SSO email form page for user to enter their email address. + - `/single-sign-on/connectors` - The SSO connectors page for user to select the enabled SSO connector they want to use. + - Implement the email identifier guard on all the sign-in and registration identifier forms. If the email address is enabled with SSO, redirect user to the SSO flow. + +### Patch Changes + +- 9421375d7: Bump libphonenumber-js to v1.10.51 to support China 19 started phone numbers. Thanks to @agileago + +## 1.3.0 + +### Minor Changes + +- 6727f629d: feature: introduce multi-factor authentication + + We're excited to announce that Logto now supports multi-factor authentication (MFA) for your sign-in experience. Navigate to the "Multi-factor auth" tab to configure how you want to secure your users' accounts. + + In this release, we introduce the following MFA methods: + + - Authenticator app OTP: users can add any authenticator app that supports the TOTP standard, such as Google Authenticator, Duo, etc. + - WebAuthn (Passkey): users can use the standard WebAuthn protocol to register a hardware security key, such as biometric keys, Yubikey, etc. + - Backup codes:users can generate a set of backup codes to use when they don't have access to other MFA methods. + + For a smooth transition, we also support to configure the MFA policy to require MFA for sign-in experience, or to allow users to opt-in to MFA. + +## 1.2.1 + +### Patch Changes + +- 6f5a0acad: fix a bug that prevents user from customizing i18n translations in Sign-in Experience config + +## 1.2.0 + +### Minor Changes + +- e8b0b1d02: feature: password policy + + ### Summary + + This feature enables custom password policy for users. Now it is possible to guard with the following rules when a user is creating a new password: + + - Minimum length (default: `8`) + - Minimum character types (default: `1`) + - If the password has been pwned (default: `true`) + - If the password is exactly the same as or made up of the restricted phrases: + - Repetitive or sequential characters (default: `true`) + - User information (default: `true`) + - Custom words (default: `[]`) + + If you are an existing Logto Cloud user or upgrading from a previous version, to ensure a smooth experience, we'll keep the original policy as much as possible: + + > The original password policy requires a minimum length of 8 and at least 2 character types (letters, numbers, and symbols). + + Note in the new policy implementation, it is not possible to combine lower and upper case letters into one character type. So the original password policy will be translated into the following: + + - Minimum length: `8` + - Minimum character types: `2` + - Pwned: `false` + - Repetitive or sequential characters: `false` + - User information: `false` + - Custom words: `[]` + + If you want to change the policy, you can do it: + + - Logto Console -> Sign-in experience -> Password policy. + - Update `passwordPolicy` property in the sign-in experience via Management API. + + ### Side effects + + - All new users will be affected by the new policy immediately. + - Existing users will not be affected by the new policy until they change their password. + - We removed password restrictions when adding or updating a user via Management API. + +### Patch Changes + +- f8408fa77: rename the package `phrases-ui` to `phrases-experience` +- f6723d5e2: rename the package `ui` to `experience` + +## 1.1.5 + +### Patch Changes + +- c743cef42: Bug fix main flow preview mode should not allow user interaction. + + - Recover the missing preview classname from the preview mode body element + +## 1.1.4 + +### Patch Changes + +- 046a5771b: upgrade i18next series packages (#3733, #3743) + +## 1.1.3 + +### Patch Changes + +- 748878ce5: add React context and hook to app-insights, fix init issue for frontend projects + +## 1.1.2 + +### Patch Changes + +- 352807b16: support setting cloud role name for AppInsights in React + +## 1.1.1 + +### Patch Changes + +- 4945b0be2: Apply security headers + + Apply security headers to logto http request response using (helmetjs)[https://helmetjs.github.io/]. + + - [x] crossOriginOpenerPolicy + - [x] crossOriginEmbedderPolicy + - [x] crossOriginResourcePolicy + - [x] hidePoweredBy + - [x] hsts + - [x] ieNoOpen + - [x] noSniff + - [x] referrerPolicy + - [x] xssFilter + - [x] Content-Security-Policy + +## 1.1.0 + +## 1.0.3 + +## 1.0.2 + +## 1.0.1 + +## 1.0.0 + +### Major Changes + +- 1c9160112: ### Features + + - Enhanced user search params #2639 + - Web hooks + + ### Improvements + + - Refactored Interaction APIs and Audit logs + +- 343b1090f: **💥 BREAKING CHANGE 💥** Move `/api/phrase` API to `/api/.well-known/phrases` + +### Minor Changes + +- 343b1090f: ### Simplify the terms of use and privacy policy manual agreement steps for the sign-in flow + + The Terms of Use and Privacy Policy manuel agreement are now removed from the sign-in flow. + + - The changes may take effect on all the existing sign-in flows, including password sign-in, social sign-in, and verification-code sign-in. + - The agreement checkbox in sign-in pages is now replaced with links to the Terms of Use and Privacy Policy pages. Users can still read the agreements before signing in. + - The manual agreement steps are still mandatory for the sign-up flow. Users must agree to the Terms of Use and Privacy Policy before signing up a new account. Including sign-up with new social identities. The agreement checkbox in sign-up pages remain still. + +- f41fd3f05: Replace `passcode` naming convention in the interaction APIs and main flow ui with `verificationCode`. +- 343b1090f: ### Update the password policy + + Password policy description: Password requires a minimum of 8 characters and contains a mix of letters, numbers, and symbols. + + - min-length updates: Password requires a minimum of 8 characters + - allowed characters updates: Password contains a mix of letters, numbers, and symbols + - digits: 0-9 + - letters: a-z, A-Z + - symbols: !"#$%&'()\*+,./:;<=>?@[\]^\_`{|}~- + - At least two types of characters are required: + - letters and digits + - letters and symbols + - digits and symbols + + > notice: The new password policy is applied to new users or new passwords only. Existing users are not affected by this change, users may still use their old password to sign-in. + +- 343b1090f: ### Add dynamic favicon and html title + + - Add the favicon field in the sign-in-experience branding settings. Users would be able to upload their own favicon. Use local logto icon as a fallback + + - Set different html title for different pages. + - sign-in + - register + - forgot-password + - logto + +- 343b1090f: Allow admin tenant admin to create tenants without limitation +- 343b1090f: ## Add iframe modal for mobile platform + + Implement a full screen iframe modal on the mobile platform. As for most of the webview containers, opening a new tab is not allowed. So we need to implement a full screen iframe modal to show the external link page on the mobile platform. + +- 343b1090f: New feature: User account settings page + + - We have removed the previous settings page and moved it to the account settings page. You can access to the new settings menu by clicking the user avatar in the top right corner. + - You can directly change the language or theme from the popover menu, and explore more account settings by clicking the "Profile" menu item. + - You can update your avatar, name and username in the profile page, and also changing your password. + - [Cloud] Cloud users can also link their email address and social accounts (Google and GitHub at first launch). + +- c12717412: ## Smart Identifier Input designed to streamline your sign-in experience + + - Smart Contact Input + - Smart Identifier Input + - Intelligent Identifier Input Field + + Content: + We have integrated the traditional input fields for username, phone number, and email into a single intelligent input box. This advanced input box automatically identifies the type of characters you’re entering, such as an @ sign or consecutive numbers, and provides relevant error feedback. By streamlining the sign-in process, users no longer need to waste time figuring out which button to click to switch their desired login method. This reduces the risk of errors and ensures a smoother sign-in experience. + +- 343b1090f: Implement a country code selector dropdown component with search box. Users may able to quick search for a country code by typing in the search box. +- 343b1090f: remove the branding style config and make the logo URL config optional +- c12717412: **Customize CSS for Sign-in Experience** + + We have put a lot of effort into improving the user sign-in experience and have provided a brand color option for the UI. However, we know that fine-tuning UI requirements can be unpredictable. While Logto is still exploring the best options for customization, we want to provide a programmatic method to unblock your development. + + You can now use the Management API `PATCH /api/sign-in-exp` with body `{ "customCss": "arbitrary string" }` to set customized CSS for the sign-in experience. You should see the value of `customCss` attached after `` of the page. If the style has a higher priority, it should be able to override. + + > **Note** + > + > Since Logto uses CSS Modules, you may see a hash value in the `class` property of DOM elements (e.g. a `<div>` with `vUugRG_container`). To override these, you can use the `$=` CSS selector to match elements that end with a specified value. In this case, it should be `div[class$=container]`. + +- 343b1090f: Add custom CSS code editor so that users can apply advanced UI customization. + - Users can check the real time preview of the CSS via SIE preview on the right side. +- 2168936b9: **Sign-in Experience v2** + + We are thrilled to announce the release of the newest version of the Sign-in Experience, which includes more ways to sign-in and sign-up, as well as a framework that is easier to understand and more flexible to configure in the Admin Console. + + When compared to Sign-in Experience v1, this version’s capability was expanded so that it could support a greater variety of flexible use cases. For example, now users can sign up with email verification code and sign in with email and password. + + We hope that this will be able to assist developers in delivering a successful sign-in flow, which will also be appreciated by the end users. + +- 343b1090f: ### Add custom content sign-in-experience settings to allow insert custom static html content to the logto sign-in pages + + - feat: combine with the custom css, give the user the ability to further customize the sign-in pages + +- fdb2bb48e: **Streamlining the social sign-up flow** + + - detect trusted email (or phone number) from the social account + - email (or phone number) has been registered: automatically connecting the social identity to the existing user account with a single click + - email (or phone number) not registered: automatically sync up the user profile with the social provided email (or phone) if and only if marked as a required user profile. + +- f41fd3f05: Replace the `sms` naming convention using `phone` cross logto codebase. Including Sign-in Experience types, API paths, API payload and internal variable names. + +### Patch Changes + +- 51f527b0c: bug fixes + + - core: fix 500 error when enabling app admin access in console + - ui: handle required profile errors on social binding flow + +- 343b1090f: ## Implement a lite version of set password form. + + To simplify the effort when user set new password, we implement a lite version of set password form. + + The lite version of set password form only contains only one field password. It will be used if and only if the forgot-password feature is enabled (password can be reset either by email and phone). + + If you do not have any email or sms service enabled, we still use the old version of set password form which contains two fields: password and confirm password. + +- 38970fb88: Fix a Sign-in experience bug that may block some users to sign in. +- 02cc9abd8: Fix a bug to show forgot password when only SMS connector is configured +- 343b1090f: - Add Power By Logto Signature to the main-flow pages + +## 1.0.0-rc.3 + +## 1.0.0-rc.2 + +### Minor Changes + +- c12717412: ## Smart Identifier Input designed to streamline your sign-in experience + + - Smart Contact Input + - Smart Identifier Input + - Intelligent Identifier Input Field + + Content: + We have integrated the traditional input fields for username, phone number, and email into a single intelligent input box. This advanced input box automatically identifies the type of characters you’re entering, such as an @ sign or consecutive numbers, and provides relevant error feedback. By streamlining the sign-in process, users no longer need to waste time figuring out which button to click to switch their desired login method. This reduces the risk of errors and ensures a smoother sign-in experience. + +- c12717412: **Customize CSS for Sign-in Experience** + + We have put a lot of effort into improving the user sign-in experience and have provided a brand color option for the UI. However, we know that fine-tuning UI requirements can be unpredictable. While Logto is still exploring the best options for customization, we want to provide a programmatic method to unblock your development. + + You can now use the Management API `PATCH /api/sign-in-exp` with body `{ "customCss": "arbitrary string" }` to set customized CSS for the sign-in experience. You should see the value of `customCss` attached after `<title>` of the page. If the style has a higher priority, it should be able to override. + + > **Note** + > + > Since Logto uses CSS Modules, you may see a hash value in the `class` property of DOM elements (e.g. a `<div>` with `vUugRG_container`). To override these, you can use the `$=` CSS selector to match elements that end with a specified value. In this case, it should be `div[class$=container]`. + +## 1.0.0-rc.1 + +### Patch Changes + +- 51f527b0: bug fixes + + - core: fix 500 error when enabling app admin access in console + - ui: handle required profile errors on social binding flow + +## 1.0.0-rc.0 + +### Minor Changes + +- f41fd3f0: Replace `passcode` naming convention in the interaction APIs and main flow ui with `verificationCode`. +- fdb2bb48: **Streamlining the social sign-up flow** + + - detect trusted email (or phone number) from the social account + - email (or phone number) has been registered: automatically connecting the social identity to the existing user account with a single click + - email (or phone number) not registered: automatically sync up the user profile with the social provided email (or phone) if and only if marked as a required user profile. + +- f41fd3f0: Replace the `sms` naming convention using `phone` cross logto codebase. Including Sign-in Experience types, API paths, API payload and internal variable names. + +## 1.0.0-beta.19 + +## 1.0.0-beta.18 + +### Major Changes + +- 1c916011: ### Features + + - Enhanced user search params #2639 + - Web hooks + + ### Improvements + + - Refactored Interaction APIs and Audit logs + +## 1.0.0-beta.17 + +### Patch Changes + +- 02cc9abd: Fix a bug to show forgot password when only SMS connector is configured + +## 1.0.0-beta.16 + +### Patch Changes + +- 38970fb8: Fix a Sign-in experience bug that may block some users to sign in. + +## 1.0.0-beta.15 + +## 1.0.0-beta.14 + +## 1.0.0-beta.13 + +### Minor Changes + +- 2168936b: **Sign-in Experience v2** + + We are thrilled to announce the release of the newest version of the Sign-in Experience, which includes more ways to sign-in and sign-up, as well as a framework that is easier to understand and more flexible to configure in the Admin Console. + + When compared to Sign-in Experience v1, this version’s capability was expanded so that it could support a greater variety of flexible use cases. For example, now users can sign up with email verification code and sign in with email and password. + + We hope that this will be able to assist developers in delivering a successful sign-in flow, which will also be appreciated by the end users. + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [1.0.0-beta.12](https://github.com/logto-io/logto/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2022-10-19) + +**Note:** Version bump only for package @logto/ui + +## [1.0.0-beta.11](https://github.com/logto-io/logto/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2022-10-19) + +### Features + +- **ui:** add a11y support ([#2076](https://github.com/logto-io/logto/issues/2076)) ([2249d71](https://github.com/logto-io/logto/commit/2249d717a8928597d00c383c268d6fdc506ac437)) +- **ui:** add reset password error handling flow ([#2079](https://github.com/logto-io/logto/issues/2079)) ([afa2ac4](https://github.com/logto-io/logto/commit/afa2ac47ee461e3526f61594e456d484fd3166af)) +- **ui:** global confirm modal ([#2018](https://github.com/logto-io/logto/issues/2018)) ([f1ca49c](https://github.com/logto-io/logto/commit/f1ca49c89253daef8b47ec88e30f69df818374d1)) + +### Bug Fixes + +- **console:** remove connector id and prevent text overflow ([#2072](https://github.com/logto-io/logto/issues/2072)) ([05b5025](https://github.com/logto-io/logto/commit/05b50250a387635649614aaeeec9757e7034a19d)) +- **ui:** fix ut ([9ea6a8c](https://github.com/logto-io/logto/commit/9ea6a8c8e94e116d8efbbff63b39738162cbaec1)) +- **ui:** revert color token changes in ui as it uses different design system ([489e2b3](https://github.com/logto-io/logto/commit/489e2b3a1129fbbf824955e4697c1d64ff294d95)) + +## [1.0.0-beta.10](https://github.com/logto-io/logto/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2022-09-28) + +### Features + +- **ui:** add forget password flow ([#1952](https://github.com/logto-io/logto/issues/1952)) ([ba787b4](https://github.com/logto-io/logto/commit/ba787b434ba4dd43064c56115eabfdba9912f98a)) +- **ui:** add forget password page ([#1943](https://github.com/logto-io/logto/issues/1943)) ([39d80d9](https://github.com/logto-io/logto/commit/39d80d991235c93346c26977541d3c7040379a13)) +- **ui:** add passwordless switch ([#1976](https://github.com/logto-io/logto/issues/1976)) ([ddb0e47](https://github.com/logto-io/logto/commit/ddb0e47950b3bd7f92af2a8a5e14b201e0a10ed7)) +- **ui:** add reset password form ([#1964](https://github.com/logto-io/logto/issues/1964)) ([f97ec56](https://github.com/logto-io/logto/commit/f97ec56fbf169538cff5f8f23ed8bb67e9483b27)) +- **ui:** add reset password page ([#1961](https://github.com/logto-io/logto/issues/1961)) ([ff81b0f](https://github.com/logto-io/logto/commit/ff81b0f83e86dd3686341d3612f3f5e8f075cba6)) + +### Bug Fixes + +- bump react sdk and essentials toolkit to support CJK characters in idToken ([2f92b43](https://github.com/logto-io/logto/commit/2f92b438644bd330fa4b8cd3698d9129ecbae282)) +- **ui:** align mobile input outline ([#1991](https://github.com/logto-io/logto/issues/1991)) ([c9ba198](https://github.com/logto-io/logto/commit/c9ba198b59ae52d3c5b4520a98864519d7a756f7)) + +## [1.0.0-beta.9](https://github.com/logto-io/logto/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2022-09-07) + +### Features + +- add Portuguese translation ([f268ecb](https://github.com/logto-io/logto/commit/f268ecb1a8d57d1e33225bec8852f3bc377dd478)) + +### Bug Fixes + +- **console,ui:** fix locale guard issue in settings page ([e200578](https://github.com/logto-io/logto/commit/e2005780a39fa7b5f5c5e406f37805913b684c18)) + +## [1.0.0-beta.8](https://github.com/logto-io/logto/compare/v1.0.0-beta.6...v1.0.0-beta.8) (2022-09-01) + +**Note:** Version bump only for package @logto/ui + +## [1.0.0-beta.6](https://github.com/logto-io/logto/compare/v1.0.0-beta.5...v1.0.0-beta.6) (2022-08-30) + +**Note:** Version bump only for package @logto/ui + +## [1.0.0-beta.5](https://github.com/logto-io/logto/compare/v1.0.0-beta.4...v1.0.0-beta.5) (2022-08-19) + +**Note:** Version bump only for package @logto/ui + +## [1.0.0-beta.4](https://github.com/logto-io/logto/compare/v1.0.0-beta.3...v1.0.0-beta.4) (2022-08-11) + +### Bug Fixes + +- build and types ([8b51543](https://github.com/logto-io/logto/commit/8b515435cdb0644d0ca19e2c26ba3e744355bb0b)) +- **ui,console,demo-app:** update react render method ([#1750](https://github.com/logto-io/logto/issues/1750)) ([4b972f2](https://github.com/logto-io/logto/commit/4b972f2e23e2d4609d9955c4d1d42972f368f5b9)) +- **ui:** add sandbox props to iframe ([#1757](https://github.com/logto-io/logto/issues/1757)) ([62d2afe](https://github.com/logto-io/logto/commit/62d2afe9579334547b7ff5b803299b89933a5bd8)) +- **ui:** connector name should fallback to en ([#1718](https://github.com/logto-io/logto/issues/1718)) ([3af5b1b](https://github.com/logto-io/logto/commit/3af5b1b4250d6de6883b4c8a8b9f7cf4f9b12dab)) +- **ui:** extract ReactModal elementApp and fix act warning in ut ([#1756](https://github.com/logto-io/logto/issues/1756)) ([0270bf1](https://github.com/logto-io/logto/commit/0270bf1be3a51d9b9f8ed84a0327c58ed8a1bd4d)) +- **ui:** fix ui test ([e4629f2](https://github.com/logto-io/logto/commit/e4629f2a5fd26a1d8eaefd04042eaeb5563ec30c)) + +## [1.0.0-beta.3](https://github.com/logto-io/logto/compare/v1.0.0-beta.2...v1.0.0-beta.3) (2022-08-01) + +### Features + +- **phrases:** tr language ([#1707](https://github.com/logto-io/logto/issues/1707)) ([411a8c2](https://github.com/logto-io/logto/commit/411a8c2fa2bfb16c4fef5f0a55c3c1dc5ead1124)) + +## [1.0.0-beta.2](https://github.com/logto-io/logto/compare/v1.0.0-beta.1...v1.0.0-beta.2) (2022-07-25) + +### Bug Fixes + +- **ui:** fix some firefox standout bug ([#1615](https://github.com/logto-io/logto/issues/1615)) ([4ce6bd8](https://github.com/logto-io/logto/commit/4ce6bd8cf5c5953d9f62878ab2ea6ede74f6ca48)) +- **ui:** protect window.location xss ([#1639](https://github.com/logto-io/logto/issues/1639)) ([34b465c](https://github.com/logto-io/logto/commit/34b465c7d83999e2215ef83555b64e38778b8b49)) +- **ui:** should clear prev passcode input when click on backspace ([#1660](https://github.com/logto-io/logto/issues/1660)) ([7dfbc30](https://github.com/logto-io/logto/commit/7dfbc300b09cc3dac2a06176bf2cbc9f338d857e)) + +## [1.0.0-beta.1](https://github.com/logto-io/logto/compare/v1.0.0-beta.0...v1.0.0-beta.1) (2022-07-19) + +### Features + +- **ui:** add submit input to all the sign-in & register forms ([#1587](https://github.com/logto-io/logto/issues/1587)) ([0c0c83c](https://github.com/logto-io/logto/commit/0c0c83cc8f78f611f5a8527ecedd6ce21d1dad80)) + +### Bug Fixes + +- **ui:** fix no-restrict-syntax in ui ([#1559](https://github.com/logto-io/logto/issues/1559)) ([816ce9f](https://github.com/logto-io/logto/commit/816ce9f903fc939b676165c5ad7e17c72f4c1c86)) +- **ui:** format phone number with country calling code ([#1551](https://github.com/logto-io/logto/issues/1551)) ([c6384be](https://github.com/logto-io/logto/commit/c6384bed84340909aaa41f10abaea26b5195e6a5)) + +## [1.0.0-beta.0](https://github.com/logto-io/logto/compare/v1.0.0-alpha.4...v1.0.0-beta.0) (2022-07-14) + +### Bug Fixes + +- **ui,core:** fix i18n issue ([#1548](https://github.com/logto-io/logto/issues/1548)) ([6b58d8a](https://github.com/logto-io/logto/commit/6b58d8a1610b1b75155d873e8898786d2b723ec6)) +- **ui:** fix multiple libphonmenumber packed bug ([#1544](https://github.com/logto-io/logto/issues/1544)) ([e06f8d0](https://github.com/logto-io/logto/commit/e06f8d027eaea3ab89b4fd301be46af3508b61b5)) + +## [1.0.0-alpha.4](https://github.com/logto-io/logto/compare/v1.0.0-alpha.3...v1.0.0-alpha.4) (2022-07-08) + +### Bug Fixes + +- **ui:** add form submit event ([#1489](https://github.com/logto-io/logto/issues/1489)) ([f52fa58](https://github.com/logto-io/logto/commit/f52fa5891d70bf9a50c76eb3efa35f6031dc88cb)) + +## [1.0.0-alpha.3](https://github.com/logto-io/logto/compare/v1.0.0-alpha.2...v1.0.0-alpha.3) (2022-07-07) + +### Bug Fixes + +- **core,ui:** remove todo comments ([#1454](https://github.com/logto-io/logto/issues/1454)) ([d5d6c5e](https://github.com/logto-io/logto/commit/d5d6c5ed083364dabaa0220deaa6a22e0350d146)) + +## [1.0.0-alpha.2](https://github.com/logto-io/logto/compare/v1.0.0-alpha.1...v1.0.0-alpha.2) (2022-07-07) + +### Bug Fixes + +- **ui:** dark mode seed ([#1426](https://github.com/logto-io/logto/issues/1426)) ([be73dbf](https://github.com/logto-io/logto/commit/be73dbf4ef14cf49779775dd95848ba73904a4b2)) +- **ui:** set ui specific i18n storage key ([#1441](https://github.com/logto-io/logto/issues/1441)) ([5b121d7](https://github.com/logto-io/logto/commit/5b121d78551d471125737daf31d4e0505e69e409)) + +## [1.0.0-alpha.1](https://github.com/logto-io/logto/compare/v1.0.0-alpha.0...v1.0.0-alpha.1) (2022-07-05) + +**Note:** Version bump only for package @logto/ui + +## [1.0.0-alpha.0](https://github.com/logto-io/logto/compare/v0.1.2-alpha.5...v1.0.0-alpha.0) (2022-07-04) + +**Note:** Version bump only for package @logto/ui + +### [0.1.2-alpha.5](https://github.com/logto-io/logto/compare/v0.1.2-alpha.4...v0.1.2-alpha.5) (2022-07-03) + +**Note:** Version bump only for package @logto/ui + +### [0.1.2-alpha.4](https://github.com/logto-io/logto/compare/v0.1.2-alpha.3...v0.1.2-alpha.4) (2022-07-03) + +**Note:** Version bump only for package @logto/ui + +### [0.1.2-alpha.3](https://github.com/logto-io/logto/compare/v0.1.2-alpha.2...v0.1.2-alpha.3) (2022-07-03) + +**Note:** Version bump only for package @logto/ui + +### [0.1.2-alpha.2](https://github.com/logto-io/logto/compare/v0.1.2-alpha.1...v0.1.2-alpha.2) (2022-07-02) + +**Note:** Version bump only for package @logto/ui + +### [0.1.2-alpha.1](https://github.com/logto-io/logto/compare/v0.1.2-alpha.0...v0.1.2-alpha.1) (2022-07-02) + +**Note:** Version bump only for package @logto/ui + +### [0.1.2-alpha.0](https://github.com/logto-io/logto/compare/v0.1.1-alpha.0...v0.1.2-alpha.0) (2022-07-02) + +**Note:** Version bump only for package @logto/ui + +### [0.1.1-alpha.0](https://github.com/logto-io/logto/compare/v0.1.0-internal...v0.1.1-alpha.0) (2022-07-01) + +### Features + +- **connector:** apple ([#966](https://github.com/logto-io/logto/issues/966)) ([7400ed8](https://github.com/logto-io/logto/commit/7400ed8896fdceda6165a0540413efb4e3a47438)) +- **console,ui:** generate dark mode color in console ([#1231](https://github.com/logto-io/logto/issues/1231)) ([f72b21d](https://github.com/logto-io/logto/commit/f72b21d1602ab0fb35ef3e7d84f6c8ebd7e18b08)) +- **console:** add 404 page in admin console ([0d047fb](https://github.com/logto-io/logto/commit/0d047fbaf115f005615b5df06170e526283d9335)) +- **console:** add mobile web tab in preview ([#1214](https://github.com/logto-io/logto/issues/1214)) ([9b6fd4c](https://github.com/logto-io/logto/commit/9b6fd4c417f2ee53375e436c839b711c86403d58)) +- **console:** sie form reorg ([#1218](https://github.com/logto-io/logto/issues/1218)) ([2c41334](https://github.com/logto-io/logto/commit/2c413341d1c515049faa130416f7a5e591d10e8a)) +- **core,connectors:** update Aliyun logo and add logo_dark to Apple, Github ([#1194](https://github.com/logto-io/logto/issues/1194)) ([98f8083](https://github.com/logto-io/logto/commit/98f808320b1c79c51f8bd6f49e35ca44363ea560)) +- **core,console:** social connector targets ([#851](https://github.com/logto-io/logto/issues/851)) ([127664a](https://github.com/logto-io/logto/commit/127664a62f1b1c794569b7fe9d0bfceb7b97dc74)) +- **core:** add sign-in-mode ([#1132](https://github.com/logto-io/logto/issues/1132)) ([f640dad](https://github.com/logto-io/logto/commit/f640dad52f2e75620b392114673860138e1aca2c)) +- **core:** add socialConnectors details for get sign-in-settings ([#804](https://github.com/logto-io/logto/issues/804)) ([7a922cb](https://github.com/logto-io/logto/commit/7a922cbd331b45443f7f19a8af3dcd9156453079)) +- **core:** update connector db schema ([#732](https://github.com/logto-io/logto/issues/732)) ([8e1533a](https://github.com/logto-io/logto/commit/8e1533a70267d459feea4e5174296b17bef84d48)) +- **demo-app:** show notification in main flow ([#1038](https://github.com/logto-io/logto/issues/1038)) ([90ca76e](https://github.com/logto-io/logto/commit/90ca76eeb5460b66d2241f137f179bf4d5d6ae37)) +- **ui:** add bind social account flow ([#671](https://github.com/logto-io/logto/issues/671)) ([5e251bd](https://github.com/logto-io/logto/commit/5e251bdc0818195d7eb104488bdb8a3194205bdd)) +- **ui:** add darkmode logo ([#880](https://github.com/logto-io/logto/issues/880)) ([9fa13a2](https://github.com/logto-io/logto/commit/9fa13a24ae2e1b3024b3ef2169736d27847f04eb)) +- **ui:** add global primary color settings ([#871](https://github.com/logto-io/logto/issues/871)) ([0f2827c](https://github.com/logto-io/logto/commit/0f2827ccb873bf30e44209da39803ac6cc839af2)) +- **ui:** add mobile terms of use iframe modal ([#947](https://github.com/logto-io/logto/issues/947)) ([4abcda6](https://github.com/logto-io/logto/commit/4abcda6820f0d824d110ee3ddd6d457433dfbf26)) +- **ui:** add native sdk guard logic ([#1096](https://github.com/logto-io/logto/issues/1096)) ([147775a](https://github.com/logto-io/logto/commit/147775a8f45dbb5bbf05b3bf1b7c11c0a8acf4a4)) +- **ui:** add Notification component ([#994](https://github.com/logto-io/logto/issues/994)) ([8530e24](https://github.com/logto-io/logto/commit/8530e249aa6d63efe594a08f800be4bfb43ed77e)) +- **ui:** add social dropdown list for desktop ([#834](https://github.com/logto-io/logto/issues/834)) ([36922b3](https://github.com/logto-io/logto/commit/36922b343f06daa1c7d4125bd0066ec06962123d)) +- **ui:** app notification ([#999](https://github.com/logto-io/logto/issues/999)) ([f4e380f](https://github.com/logto-io/logto/commit/f4e380f0b1b815314b24cec1c9013d9f3bb806a7)) +- **ui:** display error message on social callback page ([#1097](https://github.com/logto-io/logto/issues/1097)) ([f3b8678](https://github.com/logto-io/logto/commit/f3b8678a8c5e938276208c222242c3fedf4d397a)) +- **ui:** implement preview mode ([#852](https://github.com/logto-io/logto/issues/852)) ([ef19fb3](https://github.com/logto-io/logto/commit/ef19fb3d27a84509613b1f1d47819c06e9a6e9d1)) +- **ui:** init destop styling foundation ([#787](https://github.com/logto-io/logto/issues/787)) ([5c02ec3](https://github.com/logto-io/logto/commit/5c02ec3bdae162bd83d26c56f7ae34ee6e505ca2)) +- **ui:** not found page ([#691](https://github.com/logto-io/logto/issues/691)) ([731ff1c](https://github.com/logto-io/logto/commit/731ff1cbdca76104845dcf3d1223953ce8e5af93)) + +### Bug Fixes + +- `lint:report` script ([#730](https://github.com/logto-io/logto/issues/730)) ([3b17324](https://github.com/logto-io/logto/commit/3b17324d189b2fe47985d0bee8b37b4ef1dbdd2b)) +- **console:** socialConnectors in preview data ([#862](https://github.com/logto-io/logto/issues/862)) ([a2cd983](https://github.com/logto-io/logto/commit/a2cd983d97097f86a07f988031b76665958ac24b)) +- revert "chore(deps): update parcel monorepo to v2.6.0" ([877bbc0](https://github.com/logto-io/logto/commit/877bbc0d2c5c0559a3fc9a8e801a13ebff2292a6)) +- **ui:** add body background color ([#831](https://github.com/logto-io/logto/issues/831)) ([be8b862](https://github.com/logto-io/logto/commit/be8b8628ba345bd8f8832b2123a43e70c236406d)) +- **ui:** add default success true for no response api ([#842](https://github.com/logto-io/logto/issues/842)) ([88600c0](https://github.com/logto-io/logto/commit/88600c012c892c969f1e5df7ec5f46874513a058)) +- **ui:** add i18n formater for zh-CN list ([#1009](https://github.com/logto-io/logto/issues/1009)) ([ca5c8aa](https://github.com/logto-io/logto/commit/ca5c8aaec1db7ffc330f50fcdc14400e06ad6f54)) +- **ui:** catch request exceptions with no response body ([#790](https://github.com/logto-io/logto/issues/790)) ([48de9c0](https://github.com/logto-io/logto/commit/48de9c072bb060f3e5aeb785d7a765a66a0912fe)) +- **ui:** fix callback link params for apple ([#985](https://github.com/logto-io/logto/issues/985)) ([362c3a6](https://github.com/logto-io/logto/commit/362c3a6e6ed3cab24a85f9e268509d31430609e4)) +- **ui:** fix ci fail ([#708](https://github.com/logto-io/logto/issues/708)) ([da49812](https://github.com/logto-io/logto/commit/da4981216452ee11cf91c8f52a1d50ef18f9a37f)) +- **UI:** fix connector target and id used in UI ([#838](https://github.com/logto-io/logto/issues/838)) ([cd46505](https://github.com/logto-io/logto/commit/cd4650508f9b1b4d2051e600afdf1e157dcf0631)) +- **ui:** fix count down bug ([#874](https://github.com/logto-io/logto/issues/874)) ([9c1e9ef](https://github.com/logto-io/logto/commit/9c1e9ef7edb39d5d15dcbb21a8789fab78326de5)) +- **ui:** fix create account page reload issue ([#832](https://github.com/logto-io/logto/issues/832)) ([e221758](https://github.com/logto-io/logto/commit/e2217584a40098d6bfcd6a745e8e0d982e8936c0)) +- **ui:** fix drawer overflow bug ([#984](https://github.com/logto-io/logto/issues/984)) ([b9131e9](https://github.com/logto-io/logto/commit/b9131e97659dece341ba4dd0cb47686a24698dcb)) +- **ui:** fix social bug ([#939](https://github.com/logto-io/logto/issues/939)) ([7a17d41](https://github.com/logto-io/logto/commit/7a17d41acf7cc068d0ec5568bcd842db51aa8b39)) +- **ui:** fix social native interaction bug ([#772](https://github.com/logto-io/logto/issues/772)) ([2161856](https://github.com/logto-io/logto/commit/2161856bcd33b66c8390b343cc3591ff284be286)) +- **ui:** fix some of the bug bash issues ([#1053](https://github.com/logto-io/logto/issues/1053)) ([db1b6d2](https://github.com/logto-io/logto/commit/db1b6d247a3d07f81ff1284b1fdbd3e7ffcc97aa)) +- **ui:** fix typo ([#792](https://github.com/logto-io/logto/issues/792)) ([13cd2c1](https://github.com/logto-io/logto/commit/13cd2c100ed32b40da72364d1f4685edd7d6d25a)) +- **ui:** fix ui i18n package error ([#713](https://github.com/logto-io/logto/issues/713)) ([34d798b](https://github.com/logto-io/logto/commit/34d798b645f16aff05b3818797b7914b5d2bc9b3)) +- **ui:** fix undefined dark-primary-color bug ([#876](https://github.com/logto-io/logto/issues/876)) ([542d878](https://github.com/logto-io/logto/commit/542d878231b98710af6e5a8ba6a5a5f74eee73a3)) +- **ui:** hide social signin method if connectors are empty ([#909](https://github.com/logto-io/logto/issues/909)) ([5e0c39e](https://github.com/logto-io/logto/commit/5e0c39e5166072c2c8d729c2e0f714507fd93ba6)) +- **ui:** input fields ([#1125](https://github.com/logto-io/logto/issues/1125)) ([20f7ad9](https://github.com/logto-io/logto/commit/20f7ad986353eb0026cbec417eaed3c334279f86)) +- **ui:** relocate svg jest config ([#856](https://github.com/logto-io/logto/issues/856)) ([d8c62c1](https://github.com/logto-io/logto/commit/d8c62c14a677d9afa8ce4b2c78cdd8fc8b1ee6c1)) +- **ui:** social bind account should back to sign-in page ([#952](https://github.com/logto-io/logto/issues/952)) ([da41369](https://github.com/logto-io/logto/commit/da41369bfd0e444190d33edef6527b32b538dbee)) +- **ui:** ui design review fix ([#697](https://github.com/logto-io/logto/issues/697)) ([15dd1a7](https://github.com/logto-io/logto/commit/15dd1a767e9eddfd37a80b47775afbe327b76d5b)) +- **ui:** ui refinement ([#855](https://github.com/logto-io/logto/issues/855)) ([1661c81](https://github.com/logto-io/logto/commit/1661c8121a9ed1620a4d8fefd51523d2be261089)) +- **ut:** fix ut ([#683](https://github.com/logto-io/logto/issues/683)) ([b0138bd](https://github.com/logto-io/logto/commit/b0138bdc0f2c43f40e20e83b621f3de3d068c171)) diff --git a/packages/experience-legacy/README.md b/packages/experience-legacy/README.md new file mode 100644 index 00000000000..862635fc943 --- /dev/null +++ b/packages/experience-legacy/README.md @@ -0,0 +1,3 @@ +# @logto/experience + +The register and sign-in experience for end-users. diff --git a/packages/experience-legacy/index.html b/packages/experience-legacy/index.html new file mode 100644 index 00000000000..2cdf8212863 --- /dev/null +++ b/packages/experience-legacy/index.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> + <title> + + + + + +
+ + + + diff --git a/packages/experience-legacy/jest.config.ts b/packages/experience-legacy/jest.config.ts new file mode 100644 index 00000000000..e9a92e6ea48 --- /dev/null +++ b/packages/experience-legacy/jest.config.ts @@ -0,0 +1,35 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + roots: ['/src'], + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/src/jest.setup.ts'], + collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}'], + coveragePathIgnorePatterns: ['/node_modules/', '/src/__mocks__/', '/src/include.d/'], + coverageReporters: ['text-summary', 'lcov'], + transform: { + '^.+\\.(t|j)sx?$': [ + '@swc/jest', + { + sourceMaps: true, + jsc: { + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + ], + '\\.(svg)$': 'jest-transformer-svg', + '\\.(png)$': 'jest-transform-stub', + }, + moduleNameMapper: { + '^@/([^?]*)(\\?.*)?$': '/src/$1', + '^@logto/shared/(.*)$': '/../shared/lib/$1', + '\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', + }, + transformIgnorePatterns: ['node_modules/(?!(.*(nanoid|jose|ky|@logto|@silverhand))/)'], +}; + +export default config; diff --git a/packages/experience-legacy/package.json b/packages/experience-legacy/package.json new file mode 100644 index 00000000000..8efd9e88b63 --- /dev/null +++ b/packages/experience-legacy/package.json @@ -0,0 +1,99 @@ +{ + "name": "@logto/experience-legacy", + "version": "1.8.0", + "license": "MPL-2.0", + "type": "module", + "private": true, + "files": [ + "dist" + ], + "scripts": { + "precommit": "lint-staged", + "start": "vite", + "dev": "vite", + "check": "tsc --noEmit", + "build": "vite build", + "lint": "eslint --ext .ts --ext .tsx src", + "lint:report": "pnpm lint --format json --output-file report.json", + "stylelint": "stylelint \"src/**/*.scss\"", + "test:ci": "jest --coverage --silent", + "test": "jest" + }, + "devDependencies": { + "@jest/types": "^29.5.0", + "@logto/connector-kit": "workspace:^4.0.0", + "@logto/core-kit": "workspace:^2.5.0", + "@logto/language-kit": "workspace:^1.1.0", + "@logto/phrases": "workspace:^1.13.0", + "@logto/phrases-experience": "workspace:^1.7.0", + "@logto/schemas": "workspace:^1.19.0", + "@react-spring/shared": "^9.6.1", + "@react-spring/web": "^9.6.1", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/eslint-config-react": "6.0.2", + "@silverhand/essentials": "^2.9.1", + "@silverhand/ts-config": "6.0.0", + "@silverhand/ts-config-react": "6.0.0", + "@simplewebauthn/browser": "^10.0.0", + "@simplewebauthn/types": "^10.0.0", + "@swc/core": "^1.3.52", + "@swc/jest": "^0.2.26", + "@testing-library/react": "^16.0.0", + "@testing-library/react-hooks": "^8.0.1", + "@types/color": "^3.0.3", + "@types/jest": "^29.4.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/react-helmet": "^6.1.6", + "@types/react-modal": "^3.13.1", + "@types/react-router-dom": "^5.3.2", + "@vitejs/plugin-react": "^4.3.1", + "browserslist": "^4.23.2", + "browserslist-to-esbuild": "^2.1.1", + "camelcase-keys": "^9.1.3", + "classnames": "^2.3.1", + "color": "^4.2.3", + "core-js": "^3.34.0", + "eslint": "^8.56.0", + "i18next": "^22.4.15", + "i18next-browser-languagedetector": "^8.0.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-transform-stub": "^2.0.0", + "jest-transformer-svg": "^2.0.0", + "js-base64": "^3.7.5", + "ky": "^1.2.3", + "libphonenumber-js": "^1.10.51", + "lint-staged": "^15.0.0", + "postcss": "^8.4.31", + "postcss-modules": "^4.3.0", + "prettier": "^3.0.0", + "react": "^18.3.1", + "react-device-detect": "^2.2.3", + "react-dom": "^18.3.1", + "react-helmet": "^6.1.0", + "react-hook-form": "^7.53.0", + "react-i18next": "^12.3.1", + "react-modal": "^3.15.1", + "react-router-dom": "^6.10.0", + "react-string-replace": "^1.0.0", + "react-timer-hook": "^3.0.5", + "react-top-loading-bar": "^2.3.1", + "stylelint": "^15.0.0", + "superstruct": "^2.0.0", + "tiny-cookie": "^2.4.1", + "typescript": "^5.5.3", + "use-debounced-loader": "^0.1.1", + "vite": "^5.3.4", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-svgr": "^4.2.0" + }, + "engines": { + "node": "^20.9.0" + }, + "stylelint": { + "extends": "@silverhand/eslint-config-react/.stylelintrc" + }, + "prettier": "@silverhand/eslint-config/.prettierrc" +} diff --git a/packages/experience-legacy/src/App.tsx b/packages/experience-legacy/src/App.tsx new file mode 100644 index 00000000000..3f2912334f8 --- /dev/null +++ b/packages/experience-legacy/src/App.tsx @@ -0,0 +1,168 @@ +import { MfaFactor, experience } from '@logto/schemas'; +import { Route, Routes, BrowserRouter } from 'react-router-dom'; + +import AppLayout from './Layout/AppLayout'; +import AppBoundary from './Providers/AppBoundary'; +import LoadingLayerProvider from './Providers/LoadingLayerProvider'; +import PageContextProvider from './Providers/PageContextProvider'; +import SettingsProvider from './Providers/SettingsProvider'; +import UserInteractionContextProvider from './Providers/UserInteractionContextProvider'; +import Callback from './pages/Callback'; +import Consent from './pages/Consent'; +import Continue from './pages/Continue'; +import DirectSignIn from './pages/DirectSignIn'; +import ErrorPage from './pages/ErrorPage'; +import ForgotPassword from './pages/ForgotPassword'; +import IdentifierRegister from './pages/IdentifierRegister'; +import IdentifierSignIn from './pages/IdentifierSignIn'; +import MfaBinding from './pages/MfaBinding'; +import BackupCodeBinding from './pages/MfaBinding/BackupCodeBinding'; +import TotpBinding from './pages/MfaBinding/TotpBinding'; +import WebAuthnBinding from './pages/MfaBinding/WebAuthnBinding'; +import MfaVerification from './pages/MfaVerification'; +import BackupCodeVerification from './pages/MfaVerification/BackupCodeVerification'; +import TotpVerification from './pages/MfaVerification/TotpVerification'; +import WebAuthnVerification from './pages/MfaVerification/WebAuthnVerification'; +import Register from './pages/Register'; +import RegisterPassword from './pages/RegisterPassword'; +import ResetPassword from './pages/ResetPassword'; +import ResetPasswordLanding from './pages/ResetPasswordLanding'; +import SignIn from './pages/SignIn'; +import SignInPassword from './pages/SignInPassword'; +import SingleSignOnConnectors from './pages/SingleSignOnConnectors'; +import SingleSignOnEmail from './pages/SingleSignOnEmail'; +import SingleSignOnLanding from './pages/SingleSignOnLanding'; +import SocialLanding from './pages/SocialLanding'; +import SocialLinkAccount from './pages/SocialLinkAccount'; +import SocialSignInWebCallback from './pages/SocialSignInWebCallback'; +import Springboard from './pages/Springboard'; +import VerificationCode from './pages/VerificationCode'; +import { UserMfaFlow } from './types'; +import { handleSearchParametersData } from './utils/search-parameters'; + +import './scss/normalized.scss'; + +handleSearchParametersData(); + +const App = () => { + return ( + + + + + + + }> + } /> + } /> + } + /> + } /> + + }> + } + /> + + {/* Sign-in */} + + } /> + } /> + + + {/* Register */} + + } /> + } /> + + + {/* Forgot password */} + + } /> + } /> + + + {/* Passwordless verification code */} + } /> + + {/* Mfa binding */} + + } /> + } /> + } /> + } /> + + + {/* Mfa verification */} + + } /> + } /> + } /> + } /> + + + {/* Continue set up missing profile */} + + } /> + + + {/* Social sign-in pages */} + + } /> + } /> + + + {/* Single sign-on */} + + {/* Single sign-on first screen landing page */} + } /> + } /> + } /> + + + {/* Consent */} + } /> + + {/* + * Identifier sign-in (first screen) + * The first screen which only display specific identifier-based sign-in methods to users + */} + } + /> + + {/* + * Identifier register (first screen) + * The first screen which only display specific identifier-based registration methods to users + */} + } + /> + + {/* + * Reset password (first screen) + * The first screen which allow users to directly access the password reset page + */} + } + /> + + } /> + + + + + + + + + ); +}; + +export default App; diff --git a/packages/experience-legacy/src/Layout/AppLayout/CustomContent.tsx b/packages/experience-legacy/src/Layout/AppLayout/CustomContent.tsx new file mode 100644 index 00000000000..06699347dda --- /dev/null +++ b/packages/experience-legacy/src/Layout/AppLayout/CustomContent.tsx @@ -0,0 +1,28 @@ +import { useLocation } from 'react-router-dom'; + +import { useSieMethods } from '@/hooks/use-sie'; + +type Props = { + readonly className?: string; +}; + +const CustomContent = ({ className }: Props) => { + const { customContent } = useSieMethods(); + const { pathname } = useLocation(); + + const customHtml = customContent?.[pathname]; + + if (!customHtml) { + return null; + } + + try { + // Expected error; CustomContent content is load from Logto remote server + // eslint-disable-next-line react/no-danger + return
; + } catch { + return null; + } +}; + +export default CustomContent; diff --git a/packages/experience-legacy/src/Layout/AppLayout/index.module.scss b/packages/experience-legacy/src/Layout/AppLayout/index.module.scss new file mode 100644 index 00000000000..b1209c7348f --- /dev/null +++ b/packages/experience-legacy/src/Layout/AppLayout/index.module.scss @@ -0,0 +1,66 @@ +@use '@/scss/underscore' as _; + +/* Main Layout */ +.viewBox { + position: absolute; + inset: 0; + overflow: auto; +} + +.container { + min-height: 100%; + @include _.flex_column(center, center); +} + +.main { + @include _.flex_column; +} + +:global(body.mobile) { + .container { + padding-bottom: env(safe-area-inset-bottom); + } + + .main { + flex: 1; + align-self: stretch; + padding: _.unit(4) _.unit(5); + position: relative; + background: var(--color-bg-body); + } + + .signature { + margin: _.unit(10) 0 _.unit(2); + } +} + +:global(body.desktop) { + .container { + padding: _.unit(5); + } + + .main { + width: 540px; + min-height: 540px; + position: relative; + padding: _.unit(6); + border-radius: 16px; + background: var(--color-bg-float); + box-shadow: var(--color-shadow-2); + } + + .signature { + position: absolute; + bottom: 0; + transform: translateY(calc(100% + _.unit(7))); + // Have to use padding instead of margin. Overflow margin spacing will be ignored by the browser. + padding-bottom: _.unit(7); + } + + @media only screen and (max-width: 580px) { + .main { + align-self: stretch; + width: auto; + } + } +} diff --git a/packages/experience-legacy/src/Layout/AppLayout/index.tsx b/packages/experience-legacy/src/Layout/AppLayout/index.tsx new file mode 100644 index 00000000000..1b0ce7beb26 --- /dev/null +++ b/packages/experience-legacy/src/Layout/AppLayout/index.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames'; +import { Outlet } from 'react-router-dom'; + +import LogtoSignature from '@/components/LogtoSignature'; +import DevelopmentTenantNotification from '@/containers/DevelopmentTenantNotification'; +import usePlatform from '@/hooks/use-platform'; +import { layoutClassNames } from '@/utils/consts'; + +import CustomContent from './CustomContent'; +import styles from './index.module.scss'; + +const AppLayout = () => { + const { isMobile } = usePlatform(); + + return ( +
+
+ {!isMobile && } +
+ + + +
+
+
+ ); +}; + +export default AppLayout; diff --git a/packages/experience-legacy/src/Layout/FirstScreenLayout/index.module.scss b/packages/experience-legacy/src/Layout/FirstScreenLayout/index.module.scss new file mode 100644 index 00000000000..18ee6754532 --- /dev/null +++ b/packages/experience-legacy/src/Layout/FirstScreenLayout/index.module.scss @@ -0,0 +1,25 @@ +@use '@/scss/underscore' as _; + +.wrapper { + @include _.full-page; + @include _.flex-column(normal, normal); + @include _.full-width; + + > *:last-child { + margin-bottom: 0; + } +} + +:global(body.desktop) { + .wrapper { + padding: _.unit(6) 0; + } + + .placeholderTop { + flex: 3; + } + + .placeholderBottom { + flex: 5; + } +} diff --git a/packages/experience-legacy/src/Layout/FirstScreenLayout/index.tsx b/packages/experience-legacy/src/Layout/FirstScreenLayout/index.tsx new file mode 100644 index 00000000000..8aaa76165f2 --- /dev/null +++ b/packages/experience-legacy/src/Layout/FirstScreenLayout/index.tsx @@ -0,0 +1,28 @@ +import { type ReactNode, useContext } from 'react'; + +import PageContext from '@/Providers/PageContextProvider/PageContext'; + +import PageMeta from '../../components/PageMeta'; +import type { Props as PageMetaProps } from '../../components/PageMeta'; + +import styles from './index.module.scss'; + +type Props = { + readonly children: ReactNode; + readonly pageMeta: PageMetaProps; +}; + +const FirstScreenLayout = ({ children, pageMeta }: Props) => { + const { platform } = useContext(PageContext); + + return ( + <> + + {platform === 'web' &&
} +
{children}
+ {platform === 'web' &&
} + + ); +}; + +export default FirstScreenLayout; diff --git a/packages/experience-legacy/src/Layout/FocusedAuthPageLayout/index.module.scss b/packages/experience-legacy/src/Layout/FocusedAuthPageLayout/index.module.scss new file mode 100644 index 00000000000..bf8b5680d9f --- /dev/null +++ b/packages/experience-legacy/src/Layout/FocusedAuthPageLayout/index.module.scss @@ -0,0 +1,33 @@ +@use '@/scss/underscore' as _; + +.header { + margin: _.unit(6) 0; +} + +.description { + margin-top: _.unit(2); + @include _.text-hint; +} + +.terms { + margin-top: _.unit(4); + @include _.text-hint; + text-align: center; + font: var(--font-body-3); +} + +.link { + margin-top: _.unit(7); +} + +:global(body.mobile) { + .title { + @include _.title; + } +} + +:global(body.desktop) { + .title { + @include _.title_desktop; + } +} diff --git a/packages/experience-legacy/src/Layout/FocusedAuthPageLayout/index.tsx b/packages/experience-legacy/src/Layout/FocusedAuthPageLayout/index.tsx new file mode 100644 index 00000000000..b7c160a4cbe --- /dev/null +++ b/packages/experience-legacy/src/Layout/FocusedAuthPageLayout/index.tsx @@ -0,0 +1,61 @@ +import { type AgreeToTermsPolicy } from '@logto/schemas'; +import { type TFuncKey } from 'i18next'; +import { useMemo, type ReactNode } from 'react'; + +import DynamicT from '@/components/DynamicT'; +import type { Props as PageMetaProps } from '@/components/PageMeta'; +import type { Props as TextLinkProps } from '@/components/TextLink'; +import TextLink from '@/components/TextLink'; +import TermsAndPrivacyLinks from '@/containers/TermsAndPrivacyLinks'; +import useTerms from '@/hooks/use-terms'; + +import FirstScreenLayout from '../FirstScreenLayout'; + +import styles from './index.module.scss'; + +type Props = { + readonly children: ReactNode; + readonly pageMeta: PageMetaProps; + readonly title: TFuncKey; + readonly description: string; + readonly footerTermsDisplayPolicies?: AgreeToTermsPolicy[]; + readonly authOptionsLink: TextLinkProps; +}; + +/** + * FocusedAuthPageLayout Component + * + * This layout component is designed for focused authentication pages that serve as the first screen + * for specific auth methods, such as identifier sign-in, identifier-register, and single sign-on landing pages. + */ +const FocusedAuthPageLayout = ({ + children, + pageMeta, + title, + description, + footerTermsDisplayPolicies = [], + authOptionsLink, +}: Props) => { + const { agreeToTermsPolicy } = useTerms(); + + const shouldDisplayFooterTerms = useMemo( + () => agreeToTermsPolicy && footerTermsDisplayPolicies.includes(agreeToTermsPolicy), + [agreeToTermsPolicy, footerTermsDisplayPolicies] + ); + + return ( + +
+
+ +
+
{description}
+
+ {children} + {shouldDisplayFooterTerms && } + +
+ ); +}; + +export default FocusedAuthPageLayout; diff --git a/packages/experience-legacy/src/Layout/LandingPageLayout/index.module.scss b/packages/experience-legacy/src/Layout/LandingPageLayout/index.module.scss new file mode 100644 index 00000000000..a11c10a54f0 --- /dev/null +++ b/packages/experience-legacy/src/Layout/LandingPageLayout/index.module.scss @@ -0,0 +1,36 @@ +@use '@/scss/underscore' as _; + +.wrapper { + @include _.full-page; + @include _.flex-column(normal, normal); + @include _.full-width; + + > *:last-child { + margin-bottom: 0; + } +} + +:global(body.mobile) { + .header { + margin-top: _.unit(3); + padding-bottom: _.unit(7); + } +} + +:global(body.desktop) { + .wrapper { + padding: _.unit(6) 0; + } + + .header { + margin-bottom: _.unit(6); + } + + .placeholderTop { + flex: 3; + } + + .placeholderBottom { + flex: 5; + } +} diff --git a/packages/experience-legacy/src/Layout/LandingPageLayout/index.tsx b/packages/experience-legacy/src/Layout/LandingPageLayout/index.tsx new file mode 100644 index 00000000000..dc40e0006a4 --- /dev/null +++ b/packages/experience-legacy/src/Layout/LandingPageLayout/index.tsx @@ -0,0 +1,54 @@ +import { type ConsentInfoResponse } from '@logto/schemas'; +import classNames from 'classnames'; +import type { TFuncKey } from 'i18next'; +import type { ReactNode } from 'react'; +import { useContext } from 'react'; + +import PageContext from '@/Providers/PageContextProvider/PageContext'; +import BrandingHeader from '@/components/BrandingHeader'; +import { layoutClassNames } from '@/utils/consts'; +import { getBrandingLogoUrl } from '@/utils/logo'; + +import FirstScreenLayout from '../FirstScreenLayout'; + +import styles from './index.module.scss'; + +type ThirdPartyBranding = ConsentInfoResponse['application']['branding']; + +type Props = { + readonly children: ReactNode; + readonly title: TFuncKey; + readonly titleInterpolation?: Record; + readonly thirdPartyBranding?: ThirdPartyBranding; +}; + +const LandingPageLayout = ({ children, title, titleInterpolation, thirdPartyBranding }: Props) => { + const { experienceSettings, theme } = useContext(PageContext); + + if (!experienceSettings) { + return null; + } + + const { + color: { isDarkModeEnabled }, + branding, + } = experienceSettings; + + return ( + + + {children} + + ); +}; + +export default LandingPageLayout; diff --git a/packages/experience-legacy/src/Layout/SecondaryPageLayout/index.module.scss b/packages/experience-legacy/src/Layout/SecondaryPageLayout/index.module.scss new file mode 100644 index 00000000000..e2e8f791d7f --- /dev/null +++ b/packages/experience-legacy/src/Layout/SecondaryPageLayout/index.module.scss @@ -0,0 +1,53 @@ +@use '@/scss/underscore' as _; + +.wrapper { + @include _.full-page; +} + +.container { + @include _.full-width; + margin-top: _.unit(2); +} + +.header { + margin-bottom: _.unit(6); +} + +.description { + margin-top: _.unit(2); + @include _.text-hint; +} + +:global(body.mobile) { + .container { + margin-top: _.unit(2); + } + + .title { + @include _.title; + } + + .notification { + margin: 0 _.unit(-5) _.unit(6); + } +} + +:global(body.desktop) { + .container { + margin-top: _.unit(12); + } + + .header { + margin-bottom: _.unit(4); + } + + .title { + @include _.title_desktop; + } + + .notification { + @include _.full-width; + margin-top: _.unit(6); + border-radius: var(--radius); + } +} diff --git a/packages/experience-legacy/src/Layout/SecondaryPageLayout/index.tsx b/packages/experience-legacy/src/Layout/SecondaryPageLayout/index.tsx new file mode 100644 index 00000000000..95cdb2f54f6 --- /dev/null +++ b/packages/experience-legacy/src/Layout/SecondaryPageLayout/index.tsx @@ -0,0 +1,67 @@ +import type { TFuncKey } from 'i18next'; +import { type ReactElement } from 'react'; + +import DynamicT from '@/components/DynamicT'; +import NavBar from '@/components/NavBar'; +import PageMeta from '@/components/PageMeta'; +import usePlatform from '@/hooks/use-platform'; + +import { InlineNotification } from '../../components/Notification'; + +import styles from './index.module.scss'; + +type Props = { + readonly title: TFuncKey; + readonly description?: TFuncKey | ReactElement | ''; + readonly titleProps?: Record; + readonly descriptionProps?: Record; + readonly notification?: TFuncKey; + readonly onSkip?: () => void; + readonly isNavBarHidden?: boolean; + readonly children: React.ReactNode; +}; + +const SecondaryPageLayout = ({ + title, + description, + titleProps, + descriptionProps, + notification, + onSkip, + isNavBarHidden, + children, +}: Props) => { + const { isMobile } = usePlatform(); + + return ( +
+ + + {isMobile && notification && ( + + )} +
+
+
+ +
+ {description && ( +
+ {typeof description === 'string' ? ( + + ) : ( + description + )} +
+ )} +
+ {children} +
+ {!isMobile && notification && ( + + )} +
+ ); +}; + +export default SecondaryPageLayout; diff --git a/packages/experience-legacy/src/Layout/SectionLayout/index.module.scss b/packages/experience-legacy/src/Layout/SectionLayout/index.module.scss new file mode 100644 index 00000000000..704c6109013 --- /dev/null +++ b/packages/experience-legacy/src/Layout/SectionLayout/index.module.scss @@ -0,0 +1,11 @@ +@use '@/scss/underscore' as _; + +.title { + font: var(--font-title-3); +} + +.description { + font: var(--font-body-2); + color: var(--color-type-secondary); + margin-top: _.unit(1); +} diff --git a/packages/experience-legacy/src/Layout/SectionLayout/index.tsx b/packages/experience-legacy/src/Layout/SectionLayout/index.tsx new file mode 100644 index 00000000000..2ad4d101a03 --- /dev/null +++ b/packages/experience-legacy/src/Layout/SectionLayout/index.tsx @@ -0,0 +1,30 @@ +import { type TFuncKey } from 'i18next'; +import { type ReactNode } from 'react'; + +import DynamicT from '@/components/DynamicT'; + +import styles from './index.module.scss'; + +type Props = { + readonly title: TFuncKey; + readonly description: TFuncKey; + readonly titleProps?: Record; + readonly descriptionProps?: Record; + readonly children: ReactNode; +}; + +const SectionLayout = ({ title, description, titleProps, descriptionProps, children }: Props) => { + return ( +
+
+ +
+
+ +
+ {children} +
+ ); +}; + +export default SectionLayout; diff --git a/packages/experience-legacy/src/Layout/StaticPageLayout/index.module.scss b/packages/experience-legacy/src/Layout/StaticPageLayout/index.module.scss new file mode 100644 index 00000000000..27eea52374d --- /dev/null +++ b/packages/experience-legacy/src/Layout/StaticPageLayout/index.module.scss @@ -0,0 +1,7 @@ +@use '@/scss/underscore' as _; + + +.wrapper { + @include _.full-page; + @include _.flex-column; +} diff --git a/packages/experience-legacy/src/Layout/StaticPageLayout/index.tsx b/packages/experience-legacy/src/Layout/StaticPageLayout/index.tsx new file mode 100644 index 00000000000..8204e4df2eb --- /dev/null +++ b/packages/experience-legacy/src/Layout/StaticPageLayout/index.tsx @@ -0,0 +1,11 @@ +import styles from './index.module.scss'; + +type Props = { + readonly children: React.ReactNode; +}; + +const StaticPageLayout = ({ children }: Props) => { + return
{children}
; +}; + +export default StaticPageLayout; diff --git a/packages/experience-legacy/src/Providers/AppBoundary/AppMeta.tsx b/packages/experience-legacy/src/Providers/AppBoundary/AppMeta.tsx new file mode 100644 index 00000000000..5b1d0403f87 --- /dev/null +++ b/packages/experience-legacy/src/Providers/AppBoundary/AppMeta.tsx @@ -0,0 +1,56 @@ +import { Theme } from '@logto/schemas'; +import { conditionalString } from '@silverhand/essentials'; +import classNames from 'classnames'; +import i18next from 'i18next'; +import { useContext } from 'react'; +import { Helmet } from 'react-helmet'; + +import PageContext from '@/Providers/PageContextProvider/PageContext'; +import defaultAppleTouchLogo from '@/assets/apple-touch-icon.png'; +import defaultFavicon from '@/assets/favicon.png'; +import { type SignInExperienceResponse } from '@/types'; + +import styles from './index.module.scss'; + +const themeToFavicon = Object.freeze({ + [Theme.Light]: 'favicon', + [Theme.Dark]: 'darkFavicon', +} as const satisfies Record); + +/** + * User React Helmet to manage html and body attributes + * @see https://github.com/nfl/react-helmet + * + * - lang: set html lang attribute + * - data-theme: set html data-theme attribute + * - favicon: set favicon + * - apple-touch-icon: set apple touch icon + * - body class: set preview body class + * - body class: set platform body class + * - body class: set theme body class + * - custom css: set custom css style tag + */ + +const AppMeta = () => { + const { experienceSettings, theme, platform, isPreview } = useContext(PageContext); + const favicon = + experienceSettings?.branding[themeToFavicon[theme]] ?? experienceSettings?.branding.favicon; + + return ( + + + + + {experienceSettings?.customCss && } + + + ); +}; + +export default AppMeta; diff --git a/packages/experience-legacy/src/Providers/AppBoundary/index.module.scss b/packages/experience-legacy/src/Providers/AppBoundary/index.module.scss new file mode 100644 index 00000000000..29f78b78e11 --- /dev/null +++ b/packages/experience-legacy/src/Providers/AppBoundary/index.module.scss @@ -0,0 +1,37 @@ +@use '@/scss/colors' as colors; +@use '@/scss/underscore' as _; + +body { + &.light { + @include colors.light; + } + + &.dark { + @include colors.dark; + } +} + +/* Preview Settings */ +.preview { + pointer-events: none; + user-select: none; + + .viewBox::-webkit-scrollbar { + display: none; + } + + main { + pointer-events: none; + user-select: none; + } +} + +:global(body.mobile) { + --max-width: 360px; + background: var(--color-bg-body); +} + +:global(body.desktop) { + --max-width: 400px; + background: var(--color-bg-float-base); +} diff --git a/packages/experience-legacy/src/Providers/AppBoundary/index.tsx b/packages/experience-legacy/src/Providers/AppBoundary/index.tsx new file mode 100644 index 00000000000..57cea55a324 --- /dev/null +++ b/packages/experience-legacy/src/Providers/AppBoundary/index.tsx @@ -0,0 +1,30 @@ +import type { ReactElement } from 'react'; + +import useColorTheme from '@/Providers/AppBoundary/use-color-theme'; + +import ConfirmModalProvider from '../ConfirmModalProvider'; +import IframeModalProvider from '../IframeModalProvider'; +import ToastProvider from '../ToastProvider'; + +import AppMeta from './AppMeta'; + +type Props = { + readonly children: ReactElement; +}; + +const AppBoundary = ({ children }: Props) => { + useColorTheme(); + + return ( + <> + + + + {children} + + + + ); +}; + +export default AppBoundary; diff --git a/packages/experience-legacy/src/Providers/AppBoundary/use-color-theme.ts b/packages/experience-legacy/src/Providers/AppBoundary/use-color-theme.ts new file mode 100644 index 00000000000..20c2e754f1d --- /dev/null +++ b/packages/experience-legacy/src/Providers/AppBoundary/use-color-theme.ts @@ -0,0 +1,62 @@ +import { absoluteDarken, absoluteLighten } from '@logto/core-kit'; +import { Theme } from '@logto/schemas'; +import color from 'color'; +import { useEffect, useContext } from 'react'; + +import PageContext from '@/Providers/PageContextProvider/PageContext'; + +const generateLightColorLibrary = (primaryColor: color) => ({ + [`--color-brand-default`]: primaryColor.hex(), + [`--color-brand-hover`]: absoluteLighten(primaryColor, 10).string(), + [`--color-brand-pressed`]: absoluteDarken(primaryColor, 10).string(), + [`--color-brand-loading`]: absoluteLighten(primaryColor, 15).string(), + [`--color-overlay-brand-focused`]: primaryColor.alpha(0.16).string(), + [`--color-overlay-brand-hover`]: primaryColor.alpha(0.08).string(), + [`--color-overlay-brand-pressed`]: primaryColor.alpha(0.12).string(), +}); + +const generateDarkColorLibrary = (primaryColor: color) => ({ + [`--color-brand-default`]: primaryColor.hex(), + [`--color-brand-hover`]: absoluteLighten(primaryColor, 10).string(), + [`--color-brand-pressed`]: absoluteDarken(primaryColor, 10).string(), + [`--color-brand-loading`]: absoluteDarken(primaryColor, 10).string(), + [`--color-overlay-brand-focused`]: absoluteLighten(primaryColor, 30).rgb().alpha(0.16).string(), + [`--color-overlay-brand-hover`]: absoluteLighten(primaryColor, 30).rgb().alpha(0.08).string(), + [`--color-overlay-brand-pressed`]: absoluteLighten(primaryColor, 30).rgb().alpha(0.12).string(), +}); + +const useColorTheme = () => { + const { theme, experienceSettings } = useContext(PageContext); + const primaryColor = experienceSettings?.color.primaryColor; + const darkPrimaryColor = experienceSettings?.color.darkPrimaryColor; + + useEffect(() => { + if (!primaryColor) { + return; + } + + const lightPrimary = color(primaryColor); + + if (theme === Theme.Light) { + const lightColorLibrary = generateLightColorLibrary(lightPrimary); + + for (const [key, value] of Object.entries(lightColorLibrary)) { + document.body.style.setProperty(key, value); + } + + return; + } + + const darkPrimary = darkPrimaryColor + ? color(darkPrimaryColor) + : absoluteLighten(lightPrimary, 10); + + const darkColorLibrary = generateDarkColorLibrary(darkPrimary); + + for (const [key, value] of Object.entries(darkColorLibrary)) { + document.body.style.setProperty(key, value); + } + }, [darkPrimaryColor, primaryColor, theme]); +}; + +export default useColorTheme; diff --git a/packages/experience-legacy/src/Providers/ConfirmModalProvider/index.tsx b/packages/experience-legacy/src/Providers/ConfirmModalProvider/index.tsx new file mode 100644 index 00000000000..5836a5c87a8 --- /dev/null +++ b/packages/experience-legacy/src/Providers/ConfirmModalProvider/index.tsx @@ -0,0 +1,161 @@ +import type { Nullable } from '@silverhand/essentials'; +import { noop } from '@silverhand/essentials'; +import { useState, useRef, useMemo, createContext, useCallback } from 'react'; + +import type { ModalProps } from '@/components/ConfirmModal'; +import { WebModal, MobileModal } from '@/components/ConfirmModal'; +import usePlatform from '@/hooks/use-platform'; + +type ConfirmModalType = 'alert' | 'confirm'; + +type ConfirmModalState = Omit & { + ModalContent: string | (() => Nullable); + type: ConfirmModalType; + isConfirmLoading?: boolean; + isCancelLoading?: boolean; +}; + +/** + * Props for promise-based modal usage + */ +type PromiseConfirmModalProps = Omit & { + type?: ConfirmModalType; +}; + +/** + * Props for callback-based modal usage + */ +export type CallbackConfirmModalProps = PromiseConfirmModalProps & { + onConfirm?: () => Promise | void; + onCancel?: () => Promise | void; +}; + +type ConfirmModalContextType = { + showPromise: (props: PromiseConfirmModalProps) => Promise<[boolean, unknown?]>; + showCallback: (props: CallbackConfirmModalProps) => void; +}; + +export const ConfirmModalContext = createContext({ + showPromise: async () => [true], + showCallback: noop, +}); + +type Props = { + readonly children?: React.ReactNode; +}; + +const defaultModalState: ConfirmModalState = { + isOpen: false, + type: 'confirm', + ModalContent: () => null, + isConfirmLoading: false, + isCancelLoading: false, +}; + +/** + * ConfirmModalProvider component + * + * This component provides a context for managing confirm modals throughout the application. + * It supports both promise-based and callback-based usage patterns. see `usePromiseConfirmModal` and `useConfirmModal` hooks. + */ +const ConfirmModalProvider = ({ children }: Props) => { + const [modalState, setModalState] = useState(defaultModalState); + + const resolver = useRef<(value: [result: boolean, data?: unknown]) => void>(); + const callbackRef = useRef<{ + onConfirm?: () => Promise | void; + onCancel?: () => Promise | void; + }>({}); + + const { isMobile } = usePlatform(); + + const ConfirmModal = isMobile ? MobileModal : WebModal; + + const handleShowPromise = useCallback( + async ({ type = 'confirm', ...props }: PromiseConfirmModalProps) => { + resolver.current?.([false]); + + setModalState({ + isOpen: true, + type, + isConfirmLoading: false, + isCancelLoading: false, + ...props, + }); + + return new Promise<[result: boolean, data?: unknown]>((resolve) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + resolver.current = resolve; + }); + }, + [] + ); + + const handleShowCallback = useCallback( + ({ type = 'confirm', onConfirm, onCancel, ...props }: CallbackConfirmModalProps) => { + resolver.current?.([false]); + + setModalState({ + isOpen: true, + type, + isConfirmLoading: false, + ...props, + }); + + // eslint-disable-next-line @silverhand/fp/no-mutation + callbackRef.current = { onConfirm, onCancel }; + }, + [] + ); + + const handleConfirm = useCallback(async (data?: unknown) => { + if (callbackRef.current.onConfirm) { + setModalState((previous) => ({ ...previous, isConfirmLoading: true })); + await callbackRef.current.onConfirm(); + } + resolver.current?.([true, data]); + setModalState(defaultModalState); + }, []); + + const handleCancel = useCallback(async (data?: unknown) => { + if (callbackRef.current.onCancel) { + setModalState((previous) => ({ ...previous, isCancelLoading: true })); + await callbackRef.current.onCancel(); + } + resolver.current?.([false, data]); + setModalState(defaultModalState); + }, []); + + const contextValue = useMemo( + () => ({ + showPromise: handleShowPromise, + showCallback: handleShowCallback, + }), + [handleShowPromise, handleShowCallback] + ); + + const { ModalContent, type, ...restProps } = modalState; + + return ( + + {children} + { + void handleConfirm(); + } + : undefined + } + onClose={() => { + void handleCancel(); + }} + > + {typeof ModalContent === 'string' ? ModalContent : } + + + ); +}; + +export default ConfirmModalProvider; diff --git a/packages/experience-legacy/src/Providers/ConfirmModalProvider/indext.test.tsx b/packages/experience-legacy/src/Providers/ConfirmModalProvider/indext.test.tsx new file mode 100644 index 00000000000..acb80bd8064 --- /dev/null +++ b/packages/experience-legacy/src/Providers/ConfirmModalProvider/indext.test.tsx @@ -0,0 +1,203 @@ +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; + +import { useConfirmModal, usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; + +import ConfirmModalProvider from '.'; + +const confirmHandler = jest.fn(); +const cancelHandler = jest.fn(); + +const PromiseConfirmModalTestComponent = () => { + const { show } = usePromiseConfirmModal(); + + const onClick = async () => { + const [result] = await show({ ModalContent: 'confirm modal content' }); + + if (result) { + confirmHandler(); + + return; + } + + cancelHandler(); + }; + + return ; +}; + +const CallbackConfirmModalTestComponent = () => { + const { show } = useConfirmModal(); + + const onClick = () => { + show({ + ModalContent: 'confirm modal content', + onConfirm: confirmHandler, + onCancel: cancelHandler, + }); + }; + + return ; +}; + +describe('confirm modal provider', () => { + describe('promise confirm modal', () => { + it('render confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); + }); + + it('confirm callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + }); + + const confirm = getByText('action.confirm'); + + act(() => { + fireEvent.click(confirm); + }); + + await waitFor(() => { + expect(confirmHandler).toBeCalled(); + }); + }); + + it('cancel callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); + + const cancel = getByText('action.cancel'); + + act(() => { + fireEvent.click(cancel); + }); + + await waitFor(() => { + expect(cancelHandler).toBeCalled(); + }); + }); + }); + + describe('callback confirm modal', () => { + it('render confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); + }); + + it('confirm callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.confirm')).not.toBeNull(); + }); + + const confirm = getByText('action.confirm'); + + act(() => { + fireEvent.click(confirm); + }); + + await waitFor(() => { + expect(confirmHandler).toBeCalled(); + }); + }); + + it('cancel callback of confirm modal', async () => { + const { queryByText, getByText } = render( + + + + ); + + const trigger = getByText('show modal'); + + act(() => { + fireEvent.click(trigger); + }); + + await waitFor(() => { + expect(queryByText('confirm modal content')).not.toBeNull(); + expect(queryByText('action.cancel')).not.toBeNull(); + }); + + const cancel = getByText('action.cancel'); + + act(() => { + fireEvent.click(cancel); + }); + + await waitFor(() => { + expect(cancelHandler).toBeCalled(); + }); + }); + }); +}); diff --git a/packages/experience-legacy/src/Providers/IframeModalProvider/IframeModal/index.module.scss b/packages/experience-legacy/src/Providers/IframeModalProvider/IframeModal/index.module.scss new file mode 100644 index 00000000000..643d69bb2e8 --- /dev/null +++ b/packages/experience-legacy/src/Providers/IframeModalProvider/IframeModal/index.module.scss @@ -0,0 +1,71 @@ +@use '@/scss/underscore' as _; + + +.overlay { + z-index: 101; +} + +.modal { + z-index: 101; + position: absolute; + inset: 0; + overflow: auto; +} + +.container { + background: var(--color-bg-body); + height: 100%; + @include _.flex-column; + align-items: stretch; + overflow: hidden; +} + +.modal, +.container { + &:focus-visible { + outline: none; + } +} + +.header { + padding: _.unit(2) _.unit(5); +} + +.content { + flex: 1; + width: 100%; +} + +.iframe { + width: 100%; + height: 100%; + border: none; + background: var(--color-bg-body); + opacity: 0%; + transition: opacity 0.2s ease-in-out; + + &.loaded { + opacity: 100%; + } +} + +.loader { + background: var(--color-brand-default); +} + +/* stylelint-disable selector-class-pattern */ +:global { + .ReactModal__Content[id='iframe-modal'] { + transform: translateY(100%); + transition: transform 0.3s ease-in-out; + } + + .ReactModal__Content--after-open[id='iframe-modal'] { + transform: translateY(0); + } + + .ReactModal__Content--before-close[id='iframe-modal'] { + transform: translateY(100%); + } +} +/* stylelint-enable selector-class-pattern */ diff --git a/packages/experience-legacy/src/Providers/IframeModalProvider/IframeModal/index.tsx b/packages/experience-legacy/src/Providers/IframeModalProvider/IframeModal/index.tsx new file mode 100644 index 00000000000..0db5d5866ea --- /dev/null +++ b/packages/experience-legacy/src/Providers/IframeModalProvider/IframeModal/index.tsx @@ -0,0 +1,71 @@ +import classNames from 'classnames'; +import { useRef, useState } from 'react'; +import ReactModal from 'react-modal'; +import type { LoadingBarRef } from 'react-top-loading-bar'; +import LoadingBar from 'react-top-loading-bar'; + +import NavBar from '@/components/NavBar'; + +import styles from './index.module.scss'; + +type ModalProps = { + readonly className?: string; + readonly title?: string; + readonly href?: string; + readonly onClose: () => void; +}; + +const IframeModal = ({ className, title = '', href = '', onClose }: ModalProps) => { + const [isLoaded, setIsLoaded] = useState(false); + const loadingBarRef = useRef(null); + + const brandingColor = document.body.style.getPropertyValue('--color-brand-default') || '#5d34f2'; + + return ( + { + loadingBarRef.current?.continuousStart(); + }} + onRequestClose={onClose} + > +
+
+ +
+ +
+