From 60a9d4eb7e376cc4e6e2e9b3d31bf81747f56bd4 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 21 Aug 2024 10:30:28 +0800 Subject: [PATCH] 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 | 59 ++-- .../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 | 85 +++--- ...-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 | 65 +++-- .../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 11811cada..af58d3afb 100644 --- a/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx +++ b/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx @@ -1,4 +1,4 @@ -import { type SsoConnectorMetadata } from '@logto/schemas'; +import { type SsoConnectorMetadata, type VerificationType } from '@logto/schemas'; import { noop } from '@silverhand/essentials'; import { createContext } from 'react'; @@ -6,6 +6,7 @@ import { type IdentifierInputType, type IdentifierInputValue, } from '@/components/InputFields/SmartInputField'; +import { type VerificationIdsMap } from '@/types/guard'; export type UserInteractionContextType = { // All the enabled sso connectors @@ -54,6 +55,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. * @@ -79,5 +82,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 663b171ea..8a2de7b2e 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 IdentifierInputType, @@ -38,6 +38,10 @@ const UserInteractionContextProvider = ({ children }: Props) => { IdentifierInputValue | undefined >(get(StorageKeys.ForgotPasswordIdentifierInputValue)); + const [verificationIdsMap, setVerificationIdsMap] = useState( + get(StorageKeys.verificationIds) ?? {} + ); + useEffect(() => { if (!ssoEmail) { remove(StorageKeys.SsoEmail); @@ -74,6 +78,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] @@ -94,8 +107,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, @@ -108,6 +126,8 @@ const UserInteractionContextProvider = ({ children }: Props) => { setIdentifierInputValue, forgotPasswordIdentifierInputValue, setForgotPasswordIdentifierInputValue, + verificationIdsMap, + setVerificationId, clearInteractionContextSessionStorage, }), [ @@ -117,6 +137,8 @@ const UserInteractionContextProvider = ({ children }: Props) => { identifierInputValue, getIdentifierInputValueByTypes, 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 000000000..992d54ce9 --- /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 50fa4ddf1..000000000 --- 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 000000000..6e77fb820 --- /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 000000000..4b4267548 --- /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 000000000..9e5a63685 --- /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 000000000..f2ab7b5e5 --- /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 000000000..95780446a --- /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 096ba32a3..000000000 --- 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 12d5c80e2..000000000 --- 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 82aa9e2e9..0f9935a84 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); + 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); + } + } +}; + +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); + } } - - if (type === UserFlow.SignIn) { - await putInteraction(InteractionEvent.SignIn); - } - - if (type === UserFlow.Register) { - await putInteraction(InteractionEvent.Register); - } - - return sendVerificationCode(payload); }; diff --git a/packages/experience/src/components/IdentifierRegisterForm/index.test.tsx b/packages/experience/src/components/IdentifierRegisterForm/index.test.tsx index 57ae0b43a..b4cc20e83 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 662cda6d8..b2b1eb10a 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 5d55680c7..97d65394d 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 98950a5ee..685c86cf2 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 5733b65b2..118ed73fa 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 dd035d493..1bf7f188b 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 3ea08c110..e97b58ebf 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 a15080bb5..343ee83a2 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 3c05c6e93..7ae226a74 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 45dfbb1a9..ee37b49c0 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 26ee193fe..10f9ebd88 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 946d42cfa..51aea1272 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 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 { addProfileWithVerificationCodeIdentifier } from '@/apis/interaction'; +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); + const identifierExistErrorHandler = useCallback(async () => { + const linkSocial = searchParameters.get(SearchParameters.LinkSocial); + const socialVerificationId = verificationIdsMap[VerificationType.Social]; - return; - } + // Show bind with social confirm modal + if (linkSocial && socialVerificationId) { + await showLinkSocialConfirmModal(identifier, verificationId, socialVerificationId); - await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, method, target); - }, - [searchParameters, showIdentifierErrorAlert, showLinkSocialConfirmModal] - ); + 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 ee9c4a308..6775fc63f 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 210ea5541..717c1f38a 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 5df3843d8..de2a1aedb 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 b493f0089..29956c66e 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 68f9987cf..a56687d71 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 2f358c7ab..67a9077d8 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 129fd6a34..67bb715e3 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 e6275d055..000000000 --- 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 6795170b7..550945698 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 af74b4f09..a0eed6c0e 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 e407bb323..31e1366fb 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 6fa7a7912..0269b2719 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 d3b79c616..09d1d8fb7 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 e7794bc81..a9c17f682 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 a6b62109c..1ae2f3867 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 af480682b..5fb5caea9 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 4e75ffbf1..ff378fe08 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 601780077..64fdb2220 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 000000000..710059850 --- /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 12347533b..b06087a1e 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 124cf9e31..d46d2bf94 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 ff88eb22c..a809353b3 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 353c4080d..2065e31df 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 364919ccc..bcb4d3853 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 da72cd53d..5ca612aa4 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 8bef67222..0572402b9 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 6f506cb5a..8d9cf96d4 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 1e747b8e1..2fa40c21d 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 0688037fa..628029750 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', () => { @@ -48,6 +47,8 @@ describe('ForgotPasswordForm', () => { Object.defineProperty(window, 'location', { value: originalLocation, }); + + jest.clearAllMocks(); }); describe.each([ @@ -85,8 +86,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 1cf8cda40..2c49bad77 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 3f46d54e0..a4bded77c 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 7d4bd534b..45705aa66 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 610fb5c4e..5c7106fc5 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 e825f0684..82db65e42 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 81aef9f3c..cbe889b38 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 24fd89a38..5d57db5bb 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 397566861..eddf44d80 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,28 +40,42 @@ 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 - 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; } - }, - [navigate, setForgotPasswordIdentifierInputValue, setToast, t] - ); - const [action] = usePasswordAction({ - api: setUserPassword, - setErrorMessage, - errorHandlers, - successHandler, - }); + 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 }); + }, + [ + asyncResetPassword, + checkPassword, + errorHandlers, + handleError, + navigate, + setForgotPasswordIdentifierInputValue, + setToast, + t, + ] + ); const { policy: { @@ -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 cf255021d..159e9cc87 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 e8ab0511f..e330cda41 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 3a0d1015d..0bad1d208 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 15f094c2b..ac6056981 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 8009817c2..485dd0603 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 bfc0b8da1..846acfc19 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 6f1c17b05..929714fa3 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 35f3ac1b6..aaa338171 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 311eacede..cdf4b3507 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 000000000..1f2723f6d --- /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 03837d4d6..2e28a0b71 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 667ad00e2..df42ad62a 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 6540c54fa..b39109b4c 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, +});