diff --git a/packages/experience/src/apis/api.ts b/packages/experience/src/apis/api.ts index fb1044b49e69..73a136ccc985 100644 --- a/packages/experience/src/apis/api.ts +++ b/packages/experience/src/apis/api.ts @@ -1,7 +1,10 @@ import i18next from 'i18next'; import ky from 'ky'; +import { kyPrefixUrl } from './const'; + export default ky.extend({ + prefixUrl: kyPrefixUrl, hooks: { beforeRequest: [ (request) => { diff --git a/packages/experience/src/apis/consent.ts b/packages/experience/src/apis/consent.ts index 4eb8b6fef3f7..11e46647e0fe 100644 --- a/packages/experience/src/apis/consent.ts +++ b/packages/experience/src/apis/consent.ts @@ -8,7 +8,7 @@ export const consent = async (organizationId?: string) => { }; return api - .post('/api/interaction/consent', { + .post('api/interaction/consent', { json: { organizationIds: organizationId && [organizationId], }, @@ -17,5 +17,5 @@ export const consent = async (organizationId?: string) => { }; export const getConsentInfo = async () => { - return api.get('/api/interaction/consent').json(); + return api.get('api/interaction/consent').json(); }; diff --git a/packages/experience/src/apis/const.ts b/packages/experience/src/apis/const.ts new file mode 100644 index 000000000000..992d54ce9b05 --- /dev/null +++ b/packages/experience/src/apis/const.ts @@ -0,0 +1 @@ +export const kyPrefixUrl = '/'; diff --git a/packages/experience/src/apis/experience/const.ts b/packages/experience/src/apis/experience/const.ts new file mode 100644 index 000000000000..230d311774d8 --- /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.ts b/packages/experience/src/apis/experience/index.ts similarity index 61% rename from packages/experience/src/apis/experience.ts rename to packages/experience/src/apis/experience/index.ts index aa14f578d309..4b4267548e97 100644 --- a/packages/experience/src/apis/experience.ts +++ b/packages/experience/src/apis/experience/index.ts @@ -1,60 +1,42 @@ import { - type IdentificationApiPayload, InteractionEvent, type PasswordVerificationPayload, SignInIdentifier, - type UpdateProfileApiPayload, type VerificationCodeIdentifier, } from '@logto/schemas'; -import api from './api'; +import { type ContinueFlowInteractionEvent } from '@/types'; -const prefix = '/api/experience'; +import api from '../api'; -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(); +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'; -const updateProfile = async (payload: UpdateProfileApiPayload) => { - await api.post(experienceApiRoutes.profile, { json: payload }); +export const registerWithVerifiedIdentifier = async (verificationId: string) => { + await updateInteractionEvent(InteractionEvent.Register); + return identifyAndSubmitInteraction({ verificationId }); }; -const updateInteractionEvent = async (interactionEvent: InteractionEvent) => - api.put(`${experienceApiRoutes.prefix}/interaction-event`, { - json: { - interactionEvent, - }, - }); - -const identifyAndSubmitInteraction = async (payload?: IdentificationApiPayload) => { - await identifyUser(payload); - return submitInteraction(); +export const signInWithVerifiedIdentifier = async (verificationId: string) => { + await updateInteractionEvent(InteractionEvent.SignIn); + return identifyAndSubmitInteraction({ verificationId }); }; // Password APIs @@ -73,11 +55,11 @@ export const signInWithPasswordIdentifier = async (payload: PasswordVerification export const registerWithUsername = async (username: string) => { await initInteraction(InteractionEvent.Register); - return updateProfile({ type: SignInIdentifier.Username, value: username }); + return _updateProfile({ type: SignInIdentifier.Username, value: username }); }; export const continueRegisterWithPassword = async (password: string) => { - await updateProfile({ type: 'password', value: password }); + await _updateProfile({ type: 'password', value: password }); return identifyAndSubmitInteraction(); }; @@ -114,30 +96,45 @@ export const identifyWithVerificationCode = async (json: VerificationCodePayload return identifyAndSubmitInteraction({ verificationId }); }; -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 }); -}; - // Profile APIs -export const updateProfileWithVerificationCode = async (json: VerificationCodePayload) => { +export const updateProfileWithVerificationCode = async ( + json: VerificationCodePayload, + interactionEvent?: ContinueFlowInteractionEvent +) => { const { verificationId } = await verifyVerificationCode(json); const { identifier: { type }, } = json; - await updateProfile({ + 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(); }; diff --git a/packages/experience/src/apis/experience/interaction.ts b/packages/experience/src/apis/experience/interaction.ts new file mode 100644 index 000000000000..9e5a63685c25 --- /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 000000000000..f2ab7b5e5ee5 --- /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 000000000000..01bfd8dda4e4 --- /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.verification}/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 096ba32a35f8..000000000000 --- 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/settings.ts b/packages/experience/src/apis/settings.ts index 52d1395c3c9a..cadbcb47675f 100644 --- a/packages/experience/src/apis/settings.ts +++ b/packages/experience/src/apis/settings.ts @@ -10,6 +10,8 @@ import ky from 'ky'; import type { SignInExperienceResponse } from '@/types'; import { searchKeys } from '@/utils/search-parameters'; +import { kyPrefixUrl } from './const'; + const buildSearchParameters = (record: Record>>) => { const entries = Object.entries(record).filter((entry): entry is [string, string] => Boolean(entry[0] && entry[1]) @@ -27,7 +29,10 @@ const camelCase = (string: string): string => export const getSignInExperience = async (): Promise => { return ky - .get('/api/.well-known/sign-in-exp', { + .extend({ + prefixUrl: kyPrefixUrl, + }) + .get('api/.well-known/sign-in-exp', { searchParams: buildSearchParameters( Object.fromEntries( Object.values(searchKeys).map((key) => [camelCase(key), sessionStorage.getItem(key)]) @@ -46,6 +51,7 @@ export const getPhrases = async ({ }) => ky .extend({ + prefixUrl: kyPrefixUrl, hooks: { beforeRequest: [ (request) => { @@ -56,7 +62,7 @@ export const getPhrases = async ({ ], }, }) - .get('/api/.well-known/phrases', { + .get('api/.well-known/phrases', { searchParams: buildSearchParameters({ lng: language, }), 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 12d5c80e2fca..000000000000 --- 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 c26acc0183b5..0f9935a8458a 100644 --- a/packages/experience/src/apis/utils.ts +++ b/packages/experience/src/apis/utils.ts @@ -1,13 +1,14 @@ import { InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas'; -import { UserFlow } from '@/types'; +import { type ContinueFlowInteractionEvent, UserFlow } from '@/types'; import { initInteraction, sendVerificationCode } from './experience'; /** Move to API */ export const sendVerificationCodeApi = async ( type: UserFlow, - identifier: VerificationCodeIdentifier + identifier: VerificationCodeIdentifier, + interactionEvent?: ContinueFlowInteractionEvent ) => { switch (type) { case UserFlow.SignIn: { @@ -23,8 +24,7 @@ export const sendVerificationCodeApi = async ( return sendVerificationCode(InteractionEvent.ForgotPassword, identifier); } case UserFlow.Continue: { - // Continue flow does not have its own email template, always use sign-in template for now - return sendVerificationCode(InteractionEvent.SignIn, identifier); + return sendVerificationCode(interactionEvent ?? InteractionEvent.SignIn, identifier); } } }; diff --git a/packages/experience/src/containers/SocialLinkAccount/index.test.tsx b/packages/experience/src/containers/SocialLinkAccount/index.test.tsx index 98950a5ee30c..685c86cf2f8e 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 5733b65b2832..118ed73fa4e3 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 dd035d4931e6..1bf7f188b819 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 3ea08c1103f4..e97b58ebf89f 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 a15080bb5aa8..343ee83a2ea0 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 3c05c6e93842..7ae226a7471e 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 55c0a86aae9a..ee37b49c0e28 100644 --- a/packages/experience/src/containers/VerificationCode/index.test.tsx +++ b/packages/experience/src/containers/VerificationCode/index.test.tsx @@ -1,10 +1,14 @@ import resource from '@logto/phrases-experience'; -import { SignInIdentifier, type VerificationCodeIdentifier } 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 { identifyWithVerificationCode } from '@/apis/experience'; -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'; @@ -17,17 +21,25 @@ 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/experience', () => ({ - identifyWithVerificationCode: jest.fn().mockResolvedValue({ redirectTo: 'foo.com' }), + 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; @@ -52,7 +64,7 @@ describe('', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); afterAll(() => { @@ -111,7 +123,7 @@ describe('', () => { fireEvent.click(resendButton); }); - expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, { email }); + expect(resendVerificationCodeApi).toBeCalledWith(UserFlow.SignIn, emailIdentifier); // Reset i18n await setupI18nForTesting(); @@ -143,7 +155,7 @@ describe('', () => { }); await waitFor(() => { - expect(window.location.replace).toBeCalledWith('foo.com'); + expect(window.location.replace).toBeCalledWith(redirectTo); }); }); @@ -172,7 +184,7 @@ describe('', () => { }); await waitFor(() => { - expect(window.location.replace).toBeCalledWith('foo.com'); + expect(window.location.replace).toBeCalledWith(redirectTo); }); }); }); @@ -203,7 +215,7 @@ describe('', () => { }); await waitFor(() => { - expect(window.location.replace).toBeCalledWith('foo.com'); + expect(window.location.replace).toBeCalledWith(redirectTo); }); }); @@ -232,7 +244,7 @@ describe('', () => { }); await waitFor(() => { - expect(window.location.replace).toBeCalledWith('foo.com'); + expect(window.location.replace).toBeCalledWith(redirectTo); }); }); }); @@ -310,15 +322,18 @@ describe('', () => { } await waitFor(() => { - expect(identifyWithVerificationCode).toBeCalledWith({ - identifier: emailIdentifier, - verificationId, - code: '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); }); }); @@ -340,11 +355,14 @@ describe('', () => { } await waitFor(() => { - expect(identifyWithVerificationCode).toBeCalledWith({ - identifier: phoneIdentifier, - verificationId, - code: '111111', - }); + expect(updateProfileWithVerificationCode).toBeCalledWith( + { + identifier: phoneIdentifier, + verificationId, + code: '111111', + }, + InteractionEvent.SignIn + ); }); await waitFor(() => { 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 a24fb3216608..51aea1272231 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,8 +1,10 @@ -import type { VerificationCodeIdentifier, VerificationCodeSignInIdentifier } 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 UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import { updateProfileWithVerificationCode } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; @@ -10,6 +12,7 @@ import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler'; import { SearchParameters } from '@/types'; +import { continueFlowStateGuard } from '@/types/guard'; import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler'; import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert'; @@ -23,55 +26,63 @@ const useContinueFlowCodeVerification = ( 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(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: VerificationCodeSignInIdentifier, target: string) => { - const linkSocial = searchParameters.get(SearchParameters.LinkSocial); - - // Show bind with social confirm modal - if (linkSocial) { - await showLinkSocialConfirmModal(method, target, linkSocial); - - return; - } - await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, method, target); - }, - [searchParameters, showIdentifierErrorAlert] - ); + const identifierExistErrorHandler = useCallback(async () => { + const linkSocial = searchParameters.get(SearchParameters.LinkSocial); + const socialVerificationId = verificationIdsMap[VerificationType.Social]; + + // Show bind with social confirm modal + if (linkSocial && socialVerificationId) { + await showLinkSocialConfirmModal(identifier, verificationId, socialVerificationId); + + return; + } + const { type, value } = identifier; + await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, type, value); + }, [ + identifier, + searchParameters, + showIdentifierErrorAlert, + showLinkSocialConfirmModal, + verificationId, + verificationIdsMap, + ]); const verifyVerificationCodeErrorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.phone_already_in_use': async () => - identifierExistErrorHandler(SignInIdentifier.Phone, identifier.value), - 'user.email_already_in_use': async () => - identifierExistErrorHandler(SignInIdentifier.Email, identifier.value), + 'user.phone_already_in_use': identifierExistErrorHandler, + 'user.email_already_in_use': identifierExistErrorHandler, ...preSignInErrorHandler, ...generalVerificationCodeErrorHandlers, }), - [ - preSignInErrorHandler, - generalVerificationCodeErrorHandlers, - identifierExistErrorHandler, - identifier.value, - ] + [preSignInErrorHandler, generalVerificationCodeErrorHandlers, identifierExistErrorHandler] ); const onSubmit = useCallback( async (code: string) => { - const [error, result] = await verifyVerificationCode({ - code, - identifier, - verificationId, - }); + const [error, result] = await verifyVerificationCode( + { + code, + identifier, + verificationId, + }, + interactionEvent + ); if (error) { await handleError(error, verifyVerificationCodeErrorHandlers); @@ -88,6 +99,7 @@ const useContinueFlowCodeVerification = ( errorCallback, handleError, identifier, + interactionEvent, redirectTo, verificationId, verifyVerificationCode, 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 210ea554115e..717c1f38aaac 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 8ded6ba12724..de2a1aedb686 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,4 +1,9 @@ -import { SignInIdentifier, SignInMode, type VerificationCodeIdentifier } 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'; @@ -35,7 +40,15 @@ const useRegisterFlowCodeVerification = ( 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(); @@ -77,10 +90,10 @@ const useRegisterFlowCodeVerification = ( handleError, identifier, navigate, - preSignInErrorHandler, redirectTo, show, showIdentifierErrorAlert, + preSignInErrorHandler, signInMode, signInWithIdentifierAsync, t, @@ -92,13 +105,13 @@ const useRegisterFlowCodeVerification = ( 'user.email_already_in_use': identifierExistErrorHandler, 'user.phone_already_in_use': identifierExistErrorHandler, ...generalVerificationCodeErrorHandlers, - ...preSignInErrorHandler, + ...preRegisterErrorHandler, callback: errorCallback, }), [ identifierExistErrorHandler, generalVerificationCodeErrorHandlers, - preSignInErrorHandler, + preRegisterErrorHandler, errorCallback, ] ); 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 11e84a83fd30..a56687d711fa 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,4 +1,9 @@ -import { SignInIdentifier, SignInMode, type VerificationCodeIdentifier } 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'; @@ -25,7 +30,7 @@ const useSignInFlowCodeVerification = ( 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(identifyWithVerificationCode); @@ -35,13 +40,17 @@ const useSignInFlowCodeVerification = ( 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) { + if (signInMode === SignInMode.SignIn || !signUpMethods.includes(type)) { void showIdentifierErrorAlert(IdentifierErrorType.IdentifierNotExist, type, value); return; @@ -58,7 +67,7 @@ const useSignInFlowCodeVerification = ( const [error, result] = await registerWithIdentifierAsync(verificationId); if (error) { - await handleError(error, preSignInErrorHandler); + await handleError(error, preRegisterErrorHandler); return; } @@ -74,13 +83,14 @@ const useSignInFlowCodeVerification = ( }, [ identifier, signInMode, + signUpMethods, show, t, showIdentifierErrorAlert, registerWithIdentifierAsync, verificationId, handleError, - preSignInErrorHandler, + preRegisterErrorHandler, redirectTo, navigate, ]); 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 2f358c7ab276..67a9077d804f 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 129fd6a34f0d..67bb715e35cd 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 e6275d055d96..000000000000 --- 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 6795170b701b..550945698fb3 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 af74b4f0961f..a0eed6c0ee94 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 e407bb3231f7..31e1366fb3b6 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 dc3069da3f3e..0269b2719bb8 100644 --- a/packages/experience/src/hooks/use-send-verification-code.ts +++ b/packages/experience/src/hooks/use-send-verification-code.ts @@ -1,6 +1,7 @@ /* Replace legacy useSendVerificationCode hook with this one after the refactor */ import { SignInIdentifier } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import { useCallback, useContext, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -8,7 +9,11 @@ import UserInteractionContext from '@/Providers/UserInteractionContextProvider/U import { sendVerificationCodeApi } from '@/apis/utils'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; -import { type UserFlow, type VerificationCodeIdentifier } from '@/types'; +import { + UserFlow, + type ContinueFlowInteractionEvent, + type VerificationCodeIdentifier, +} from '@/types'; import { codeVerificationTypeMap } from '@/utils/sign-in-experience'; const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => { @@ -29,11 +34,15 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) = }; const onSubmit = useCallback( - async ({ identifier, value }: Payload) => { - const [error, result] = await asyncSendVerificationCode(flow, { - type: identifier, - value, - }); + async ({ identifier, value }: Payload, interactionEvent?: ContinueFlowInteractionEvent) => { + const [error, result] = await asyncSendVerificationCode( + flow, + { + type: identifier, + value, + }, + interactionEvent + ); if (error) { await handleError(error, { @@ -58,6 +67,12 @@ 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 }, + } + ), } ); } 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 e7794bc8103d..a9c17f682f56 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 a6b62109ca36..1ae2f3867e7d 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 af480682b4a9..5fb5caea9505 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 4e75ffbf1534..ff378fe08407 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 601780077800..64fdb222001c 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 000000000000..710059850986 --- /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 12347533b5c1..b06087a1ef49 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 124cf9e314fb..d46d2bf9488a 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 ff88eb22c805..a809353b3aef 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 353c4080d2c8..2065e31df6b6 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 364919ccc6e9..bcb4d3853a4e 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 da72cd53d1d3..5ca612aa4c80 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 8bef67222d0f..0572402b9cb8 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 6f506cb5a30a..8d9cf96d4a74 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 1e747b8e190c..2fa40c21de6c 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 0688037fa712..628029750513 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 1cf8cda40b56..2c49bad77ef1 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 3f46d54e0e74..a4bded77c3f4 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 7d4bd534b4e0..45705aa66c0f 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 610fb5c4ef3f..5c7106fc5c5a 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 e825f06847a7..82db65e42c24 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/Register/IdentifierRegisterForm/index.test.tsx b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx index e7543f5b2730..fbd3101b0723 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx +++ b/packages/experience/src/pages/Register/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/pages/RegisterPassword/index.test.tsx b/packages/experience/src/pages/RegisterPassword/index.test.tsx index 81aef9f3c275..cbe889b3804a 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 24fd89a38a42..5d57db5bb5ac 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 a50212894209..eddf44d80170 100644 --- a/packages/experience/src/pages/ResetPassword/index.tsx +++ b/packages/experience/src/pages/ResetPassword/index.tsx @@ -10,6 +10,7 @@ import useApi from '@/hooks/use-api'; import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; 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'; @@ -28,6 +29,8 @@ const ResetPassword = () => { const asyncResetPassword = useApi(resetPassword); const handleError = useErrorHandler(); + const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage }); + const errorHandlers: ErrorHandlers = useMemo( () => ({ 'session.verification_session_not_found': async (error) => { @@ -37,8 +40,9 @@ const ResetPassword = () => { 'user.same_password': (error) => { setErrorMessage(error.message); }, + ...passwordRejectionErrorHandler, }), - [navigate, setErrorMessage, show] + [navigate, passwordRejectionErrorHandler, show] ); const onSubmitHandler = useCallback( diff --git a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx index 662cda6d8857..b2b1eb10ac1e 100644 --- a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx +++ b/packages/experience/src/pages/SignIn/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/pages/SignIn/PasswordSignInForm/index.test.tsx b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx index 5d55680c7cc9..97d65394d9a1 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx +++ b/packages/experience/src/pages/SignIn/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/pages/SignInPassword/PasswordForm/index.test.tsx b/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx index cf255021db93..159e9cc8798f 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 e8ab0511f8ba..e330cda41a0d 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 3a0d1015de2b..0bad1d208981 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 15f094c2b867..ac60569813b8 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 8009817c2812..485dd0603b77 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 bfc0b8da19e4..846acfc19922 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 6f1c17b0596f..929714fa3fa4 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 35f3ac1b6067..aaa338171f12 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 e488c0b5a8ce..cdf4b3507559 100644 --- a/packages/experience/src/pages/VerificationCode/index.tsx +++ b/packages/experience/src/pages/VerificationCode/index.tsx @@ -59,7 +59,7 @@ const VerificationCode = () => { // VerificationId not found const verificationId = verificationIdsMap[codeVerificationTypeMap[type]]; if (!verificationId) { - return ; + return ; } return ( diff --git a/packages/experience/src/types/guard.ts b/packages/experience/src/types/guard.ts index fb2b62dda695..2e28a0b71dcc 100644 --- a/packages/experience/src/types/guard.ts +++ b/packages/experience/src/types/guard.ts @@ -1,4 +1,5 @@ import { + InteractionEvent, MfaFactor, MissingProfile, SignInIdentifier, @@ -82,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( @@ -143,3 +142,17 @@ const mapGuard = Object.fromEntries( */ 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 667ad00e28ac..df42ad62a949 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;