diff --git a/packages/experience/src/apis/experience.ts b/packages/experience/src/apis/experience.ts index 9d75564e2..de952d30c 100644 --- a/packages/experience/src/apis/experience.ts +++ b/packages/experience/src/apis/experience.ts @@ -4,6 +4,7 @@ import { type InteractionIdentifier, type PasswordVerificationPayload, SignInIdentifier, + type SocialVerificationCallbackPayload, type UpdateProfileApiPayload, type VerificationCodeIdentifier, } from '@logto/schemas'; @@ -48,7 +49,7 @@ const updateInteractionEvent = async (interactionEvent: InteractionEvent) => }, }); -const identifyAndSubmitInteraction = async (verificationId: string) => { +export const identifyAndSubmitInteraction = async (verificationId: string) => { await identifyUser({ verificationId }); return submitInteraction(); }; @@ -97,6 +98,16 @@ export const registerPassword = async (identifier: InteractionIdentifier, passwo return identifyAndSubmitInteraction(verificationId); }; +export const resetPassword = async (password: string) => { + await api.put(`${experienceRoutes.profile}/password`, { + json: { + password, + }, + }); + + return submitInteraction(); +}; + export const sendVerificationCode = async ( interactionEvent: InteractionEvent, identifier: VerificationCodeIdentifier @@ -150,3 +161,74 @@ export const updateProfileWithVerificationCode = async (json: VerificationCodePa verificationId, }); }; + +export const getSocialAuthorizationUrl = async ( + connectorId: string, + state: string, + redirectUri: string +) => { + await initInteraction(InteractionEvent.SignIn); + + return api + .post(`${experienceRoutes.verification}/social/${connectorId}/authorization-uri`, { + json: { + state, + redirectUri, + }, + }) + .json< + VerificationResponse & { + authorizationUri: string; + } + >(); +}; + +export const verifySocialVerification = async ( + connectorId: string, + payload: SocialVerificationCallbackPayload +) => + api + .post(`${experienceRoutes.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(`${experienceRoutes.verification}/sso/connectors`, { + searchParams: { + email, + }, + }) + .json(); + +export const getSsoAuthorizationUrl = async (connectorId: string, payload: unknown) => { + await initInteraction(InteractionEvent.SignIn); + + return api + .post(`${experienceRoutes.verification}/sso/${connectorId}/authorization-uri`, { + json: payload, + }) + .json< + VerificationResponse & { + authorizationUri: string; + } + >(); +}; + +export const signInWithSso = async ( + connectorId: string, + payload: SocialVerificationCallbackPayload & { verificationId: string } +) => { + await api.post(`${experienceRoutes.verification}/sso/${connectorId}/verify`, { + json: payload, + }); + + return identifyAndSubmitInteraction(payload.verificationId); +}; diff --git a/packages/experience/src/containers/SocialLinkAccount/index.test.tsx b/packages/experience/src/containers/SocialLinkAccount/index.test.tsx index 98950a5ee..b19b6ab2a 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, registerWithVerifiedSocial } from '@/apis/interaction'; import SocialLinkAccount from '.'; @@ -30,7 +30,7 @@ describe('SocialLinkAccount', () => { it('should render bindUser Button', async () => { const { getByText } = renderWithPageContext( - + ); const bindButton = getByText('action.bind'); @@ -57,7 +57,7 @@ describe('SocialLinkAccount', () => { }, }} > - + ); @@ -77,7 +77,7 @@ describe('SocialLinkAccount', () => { }, }} > - + ); @@ -97,7 +97,7 @@ describe('SocialLinkAccount', () => { }, }} > - + ); @@ -108,7 +108,7 @@ describe('SocialLinkAccount', () => { it('should call registerWithVerifiedSocial when click create button', async () => { const { getByText } = renderWithPageContext( - + ); const createButton = getByText('action.create_account_without_linking'); diff --git a/packages/experience/src/containers/SocialLinkAccount/index.tsx b/packages/experience/src/containers/SocialLinkAccount/index.tsx index 5733b65b2..118ed73fa 100644 --- a/packages/experience/src/containers/SocialLinkAccount/index.tsx +++ b/packages/experience/src/containers/SocialLinkAccount/index.tsx @@ -17,6 +17,7 @@ import useBindSocialRelatedUser from './use-social-link-related-user'; type Props = { readonly className?: string; readonly connectorId: string; + readonly verificationId: string; readonly relatedUser: SocialRelatedUserInfo; }; @@ -39,7 +40,7 @@ const getCreateAccountActionText = (signUpMethods: string[]): TFuncKey => { return 'action.create_account_without_linking'; }; -const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { +const SocialLinkAccount = ({ connectorId, verificationId, className, relatedUser }: Props) => { const { t } = useTranslation(); const { signUpMethods } = useSieMethods(); @@ -58,10 +59,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { title="action.bind" i18nProps={{ address: type === 'email' ? maskEmail(value) : maskPhone(value) }} onClick={() => { - void bindSocialRelatedUser({ - connectorId, - ...(type === 'email' ? { email: value } : { phone: value }), - }); + void bindSocialRelatedUser(verificationId); }} /> @@ -72,7 +70,7 @@ const SocialLinkAccount = ({ connectorId, className, relatedUser }: Props) => { { - void registerWithSocial(connectorId); + void registerWithSocial(verificationId); }} /> diff --git a/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts b/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts index dd035d493..1bf7f188b 100644 --- a/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts +++ b/packages/experience/src/containers/SocialLinkAccount/use-social-link-related-user.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { bindSocialRelatedUser } from '@/apis/interaction'; +import { bindSocialRelatedUser } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; diff --git a/packages/experience/src/containers/SocialSignInList/use-social.ts b/packages/experience/src/containers/SocialSignInList/use-social.ts index 3ea08c110..ee08c6172 100644 --- a/packages/experience/src/containers/SocialSignInList/use-social.ts +++ b/packages/experience/src/containers/SocialSignInList/use-social.ts @@ -1,18 +1,20 @@ 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'; import useTerms from '@/hooks/use-terms'; import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk'; -import { generateState, storeState, buildSocialLandingUri } from '@/utils/social-connectors'; +import { buildSocialLandingUri, generateState, storeState } from '@/utils/social-connectors'; const useSocial = () => { const { experienceSettings, theme } = useContext(PageContext); @@ -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/hooks/use-check-single-sign-on.ts b/packages/experience/src/hooks/use-check-single-sign-on.ts index 2f358c7ab..9c26daf97 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); diff --git a/packages/experience/src/hooks/use-single-sign-on-watch.ts b/packages/experience/src/hooks/use-single-sign-on-watch.ts index e7794bc81..7b7ae0f29 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(); diff --git a/packages/experience/src/hooks/use-single-sign-on.ts b/packages/experience/src/hooks/use-single-sign-on.ts index a6b62109c..1ae2f3867 100644 --- a/packages/experience/src/hooks/use-single-sign-on.ts +++ b/packages/experience/src/hooks/use-single-sign-on.ts @@ -1,6 +1,8 @@ -import { useCallback } from 'react'; +import { VerificationType } from '@logto/schemas'; +import { useCallback, useContext } from 'react'; -import { getSingleSignOnUrl } from '@/apis/single-sign-on'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { getSsoAuthorizationUrl } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk'; @@ -10,11 +12,12 @@ import useGlobalRedirectTo from './use-global-redirect-to'; const useSingleSignOn = () => { const handleError = useErrorHandler(); - const asyncInvokeSingleSignOn = useApi(getSingleSignOnUrl); + const asyncInvokeSingleSignOn = useApi(getSsoAuthorizationUrl); const redirectTo = useGlobalRedirectTo({ shouldClearInteractionContextSession: false, isReplace: false, }); + const { setVerificationId } = useContext(UserInteractionContext); /** * Native IdP Sign In Flow @@ -45,11 +48,10 @@ const useSingleSignOn = () => { const state = generateState(); storeState(state, connectorId); - const [error, redirectUrl] = await asyncInvokeSingleSignOn( - connectorId, + const [error, result] = await asyncInvokeSingleSignOn(connectorId, { state, - `${window.location.origin}/callback/${connectorId}` - ); + redirectUri: `${window.location.origin}/callback/${connectorId}`, + }); if (error) { await handleError(error); @@ -57,19 +59,23 @@ const useSingleSignOn = () => { return; } - if (!redirectUrl) { + if (!result) { return; } + const { authorizationUri, verificationId } = result; + + setVerificationId(VerificationType.EnterpriseSso, verificationId); + // Invoke Native Sign In flow if (isNativeWebview()) { - nativeSignInHandler(redirectUrl, connectorId); + nativeSignInHandler(authorizationUri, connectorId); } // Invoke Web Sign In flow - await redirectTo(redirectUrl); + await redirectTo(authorizationUri); }, - [asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo] + [asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo, setVerificationId] ); }; diff --git a/packages/experience/src/hooks/use-social-register.ts b/packages/experience/src/hooks/use-social-register.ts index 601780077..1d340508b 100644 --- a/packages/experience/src/hooks/use-social-register.ts +++ b/packages/experience/src/hooks/use-social-register.ts @@ -1,22 +1,22 @@ 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 }); 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); diff --git a/packages/experience/src/pages/ResetPassword/index.tsx b/packages/experience/src/pages/ResetPassword/index.tsx index 397566861..a50212894 100644 --- a/packages/experience/src/pages/ResetPassword/index.tsx +++ b/packages/experience/src/pages/ResetPassword/index.tsx @@ -4,11 +4,12 @@ import { useNavigate } from 'react-router-dom'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; -import { setUserPassword } from '@/apis/interaction'; +import { resetPassword } from '@/apis/experience'; import SetPassword from '@/containers/SetPassword'; +import useApi from '@/hooks/use-api'; import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; -import { type ErrorHandlers } from '@/hooks/use-error-handler'; -import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action'; +import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler'; +import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker'; import { usePasswordPolicy } from '@/hooks/use-sie'; import useToast from '@/hooks/use-toast'; @@ -22,6 +23,11 @@ const ResetPassword = () => { const navigate = useNavigate(); const { show } = usePromiseConfirmModal(); const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext); + + const checkPassword = usePasswordPolicyChecker({ setErrorMessage }); + const asyncResetPassword = useApi(resetPassword); + const handleError = useErrorHandler(); + const errorHandlers: ErrorHandlers = useMemo( () => ({ 'session.verification_session_not_found': async (error) => { @@ -34,25 +40,38 @@ const ResetPassword = () => { }), [navigate, setErrorMessage, show] ); - const successHandler: SuccessHandler = useCallback( - (result) => { - if (result) { - // Clear the forgot password identifier input value - setForgotPasswordIdentifierInputValue(undefined); - setToast(t('description.password_changed')); - navigate('/sign-in', { replace: true }); + const onSubmitHandler = useCallback( + async (password: string) => { + const success = await checkPassword(password); + + if (!success) { + return; } - }, - [navigate, setForgotPasswordIdentifierInputValue, setToast, t] - ); - const [action] = usePasswordAction({ - api: setUserPassword, - setErrorMessage, - errorHandlers, - successHandler, - }); + const [error] = await asyncResetPassword(password); + + if (error) { + await handleError(error, errorHandlers); + return; + } + + // Clear the forgot password identifier input value + setForgotPasswordIdentifierInputValue(undefined); + setToast(t('description.password_changed')); + navigate('/sign-in', { replace: true }); + }, + [ + asyncResetPassword, + checkPassword, + errorHandlers, + handleError, + navigate, + setForgotPasswordIdentifierInputValue, + setToast, + t, + ] + ); const { policy: { @@ -73,7 +92,7 @@ const ResetPassword = () => { errorMessage={errorMessage} maxLength={max} clearErrorMessage={clearErrorMessage} - onSubmit={action} + onSubmit={onSubmitHandler} /> ); diff --git a/packages/experience/src/pages/SocialLinkAccount/index.tsx b/packages/experience/src/pages/SocialLinkAccount/index.tsx index 15f094c2b..ac6056981 100644 --- a/packages/experience/src/pages/SocialLinkAccount/index.tsx +++ b/packages/experience/src/pages/SocialLinkAccount/index.tsx @@ -1,9 +1,11 @@ -import { SignInIdentifier } from '@logto/schemas'; +import { SignInIdentifier, VerificationType } from '@logto/schemas'; import type { TFuncKey } from 'i18next'; -import { useParams, useLocation } from 'react-router-dom'; +import { useContext } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { is } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import SocialLinkAccountContainer from '@/containers/SocialLinkAccount'; import { useSieMethods } from '@/hooks/use-sie'; import ErrorPage from '@/pages/ErrorPage'; @@ -36,6 +38,8 @@ const SocialLinkAccount = () => { const { connectorId } = useParams(); const { state } = useLocation(); const { signUpMethods } = useSieMethods(); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.Social]; if (!is(state, socialAccountNotExistErrorDataGuard)) { return ; @@ -45,11 +49,19 @@ const SocialLinkAccount = () => { return ; } + if (!verificationId) { + return ; + } + const { relatedUser } = state; return ( - + ); }; diff --git a/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx b/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx index 8009817c2..8b74c3380 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx +++ b/packages/experience/src/pages/SocialSignInWebCallback/index.test.tsx @@ -3,9 +3,9 @@ import { Navigate, Route, Routes, useSearchParams } from 'react-router-dom'; 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 { verifySocialVerification } from '@/apis/experience'; import { singleSignOnAuthorization } from '@/apis/single-sign-on'; import { type SignInExperienceResponse } from '@/types'; import { generateState, storeState } from '@/utils/social-connectors'; @@ -17,8 +17,9 @@ jest.mock('i18next', () => ({ language: 'en', })); -jest.mock('@/apis/interaction', () => ({ - signInWithSocial: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), +jest.mock('@/apis/experience', () => ({ + verifySocialVerification: jest.fn().mockResolvedValue({ verificationId: 'foo' }), + identifyAndSubmitInteraction: jest.fn().mockResolvedValue({ redirectTo: `/sign-in` }), })); jest.mock('@/apis/single-sign-on', () => ({ @@ -49,7 +50,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'); }); }); @@ -76,12 +77,12 @@ describe('SocialCallbackPage with code', () => { ); 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`), @@ -98,7 +99,7 @@ describe('SocialCallbackPage with code', () => { ); await waitFor(() => { - expect(signInWithSocial).not.toBeCalled(); + expect(verifySocialVerification).not.toBeCalled(); }); }); }); diff --git a/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts index bfc0b8da1..846acfc19 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts +++ b/packages/experience/src/pages/SocialSignInWebCallback/use-single-sign-on-listener.ts @@ -1,9 +1,10 @@ -import { AgreeToTermsPolicy, SignInMode, experience } from '@logto/schemas'; -import { useCallback, useEffect, useState } from 'react'; +import { AgreeToTermsPolicy, SignInMode, VerificationType, experience } from '@logto/schemas'; +import { useCallback, useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { singleSignOnAuthorization, singleSignOnRegistration } from '@/apis/single-sign-on'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import { registerWithVerifiedIdentifier, signInWithSso } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; @@ -15,13 +16,13 @@ import { validateState } from '@/utils/social-connectors'; const useSingleSignOnRegister = () => { const handleError = useErrorHandler(); - const request = useApi(singleSignOnRegistration); + const request = useApi(registerWithVerifiedIdentifier); const { termsValidation, agreeToTermsPolicy } = useTerms(); const navigate = useNavigate(); const redirectTo = useGlobalRedirectTo(); return useCallback( - async (connectorId: string) => { + async (verificationId: string) => { /** * Agree to terms and conditions first before proceeding * If the agreement policy is `Manual`, the user must agree to the terms to reach this step. @@ -32,7 +33,7 @@ const useSingleSignOnRegister = () => { return; } - const [error, result] = await request(connectorId); + const [error, result] = await request(verificationId); if (error) { await handleError(error); @@ -66,19 +67,24 @@ const useSingleSignOnListener = (connectorId: string) => { const { setToast } = useToast(); const redirectTo = useGlobalRedirectTo(); const { signInMode } = useSieMethods(); + const { verificationIdsMap } = useContext(UserInteractionContext); + const verificationId = verificationIdsMap[VerificationType.EnterpriseSso]; const handleError = useErrorHandler(); const navigate = useNavigate(); - const singleSignOnAuthorizationRequest = useApi(singleSignOnAuthorization); + const singleSignOnAuthorizationRequest = useApi(signInWithSso); const registerSingleSignOnIdentity = useSingleSignOnRegister(); const singleSignOnHandler = useCallback( - async (connectorId: string, data: Record) => { + async (connectorId: string, verificationId: string, data: Record) => { const [error, result] = await singleSignOnAuthorizationRequest(connectorId, { - ...data, - // For connector validation use - redirectUri: `${window.location.origin}/callback/${connectorId}`, + verificationId, + connectorData: { + ...data, + // For connector validation use + redirectUri: `${window.location.origin}/callback/${connectorId}`, + }, }); if (error) { @@ -92,7 +98,7 @@ const useSingleSignOnListener = (connectorId: string) => { return; } - await registerSingleSignOnIdentity(connectorId); + await registerSingleSignOnIdentity(verificationId); }, // Redirect to sign-in page if error is not handled by the error handlers global: async (error) => { @@ -138,7 +144,14 @@ const useSingleSignOnListener = (connectorId: string) => { return; } - void singleSignOnHandler(connectorId, rest); + // Validate the verificationId + if (!verificationId) { + setToast(t('error.invalid_session')); + navigate('/' + experience.routes.signIn); + return; + } + + void singleSignOnHandler(connectorId, verificationId, rest); }, [ connectorId, isConsumed, @@ -148,6 +161,7 @@ const useSingleSignOnListener = (connectorId: string) => { setToast, singleSignOnHandler, t, + verificationId, ]); return { loading }; diff --git a/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts index 6f1c17b05..197875bce 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,20 @@ 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, verifySocialVerification } from '@/apis/experience'; +import { putInteraction } from '@/apis/interaction'; 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 +36,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 update the verificationId after the initial render + // We need to store a real-time reference to 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 verifySocial = useApi(verifySocialVerification); + const asyncSignInWithSocial = useApi(identifyAndSubmitInteraction); const asyncPutInteraction = useApi(putInteraction); 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 +78,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 +127,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,6 +141,39 @@ const useSocialSignInListener = (connectorId: string) => { ] ); + 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); + } + + const [error, result] = await verifySocial(connectorId, { + verificationId: verificationIdRef.current, + connectorData: { + // For validation use only + redirectUri: `${window.location.origin}/callback/${connectorId}`, + ...data, + }, + }); + + if (error || !result) { + setLoading(false); + await handleError(error, globalErrorHandler); + return; + } + + const { verificationId } = result; + + // eslint-disable-next-line @silverhand/fp/no-mutation + verificationIdRef.current = verificationId; + setVerificationId(VerificationType.Social, verificationId); + + return result.verificationId; + }, + [asyncPutInteraction, globalErrorHandler, handleError, setVerificationId, verifySocial] + ); + const signInWithSocialHandler = useCallback( async (connectorId: string, data: Record) => { // When the callback is called from Google One Tap, the interaction event was not set yet. @@ -119,14 +181,11 @@ const useSocialSignInListener = (connectorId: string) => { await asyncPutInteraction(InteractionEvent.SignIn); } - const [error, result] = await asyncSignInWithSocial({ - connectorId, - connectorData: { - // For validation use only - redirectUri: `${window.location.origin}/callback/${connectorId}`, - ...data, - }, - }); + const verificationId = await verifySocialCallbackData(connectorId, data); + if (!verificationId) { + return; + } + const [error, result] = await asyncSignInWithSocial(verificationId); if (error) { setLoading(false); @@ -139,7 +198,13 @@ const useSocialSignInListener = (connectorId: string) => { window.location.replace(result.redirectTo); } }, - [asyncPutInteraction, asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers] + [ + asyncPutInteraction, + asyncSignInWithSocial, + handleError, + signInWithSocialErrorHandlers, + verifySocialCallbackData, + ] ); // Social Sign-in Callback Handler @@ -152,18 +217,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 (!verificationId && !isGoogleOneTap) { + setToast(t('error.invalid_session')); + navigate('/' + experience.routes.signIn); + return; + } + void signInWithSocialHandler(connectorId, rest); }, [ connectorId, @@ -174,6 +246,8 @@ const useSocialSignInListener = (connectorId: string) => { setToast, signInWithSocialHandler, t, + verificationId, + verificationIdsMap, ]); return { loading }; diff --git a/packages/experience/src/pages/VerificationCode/index.tsx b/packages/experience/src/pages/VerificationCode/index.tsx index bac733d39..02480dc0a 100644 --- a/packages/experience/src/pages/VerificationCode/index.tsx +++ b/packages/experience/src/pages/VerificationCode/index.tsx @@ -63,7 +63,7 @@ const VerificationCode = () => { // VerificationId not found const verificationId = verificationIdsMap[codeVerificationTypeMap[type]]; if (!verificationId) { - return ; + return ; } return (