mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
refactor(experience): migrate social and sso
migrate social and sso
This commit is contained in:
parent
c1b6d96105
commit
dea41c96e8
15 changed files with 329 additions and 114 deletions
|
@ -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<VerificationResponse>();
|
||||
|
||||
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<string[]>();
|
||||
|
||||
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);
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
<SettingsProvider>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
|
||||
</SettingsProvider>
|
||||
);
|
||||
const bindButton = getByText('action.bind');
|
||||
|
@ -57,7 +57,7 @@ describe('SocialLinkAccount', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
|
@ -77,7 +77,7 @@ describe('SocialLinkAccount', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
|
@ -97,7 +97,7 @@ describe('SocialLinkAccount', () => {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
|
@ -108,7 +108,7 @@ describe('SocialLinkAccount', () => {
|
|||
it('should call registerWithVerifiedSocial when click create button', async () => {
|
||||
const { getByText } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} />
|
||||
<SocialLinkAccount connectorId="github" relatedUser={relatedUser} verificationId="foo" />
|
||||
</SettingsProvider>
|
||||
);
|
||||
const createButton = getByText('action.create_account_without_linking');
|
||||
|
|
|
@ -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) => {
|
|||
<TextLink
|
||||
text={actionText}
|
||||
onClick={() => {
|
||||
void registerWithSocial(connectorId);
|
||||
void registerWithSocial(verificationId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -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<string | undefined>();
|
||||
const { setSsoEmail, setSsoConnectors, availableSsoConnectorsMap } =
|
||||
useContext(UserInteractionContext);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<typeof setUserPassword> = 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}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
|
|
|
@ -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<Parameters>();
|
||||
const { state } = useLocation();
|
||||
const { signUpMethods } = useSieMethods();
|
||||
const { verificationIdsMap } = useContext(UserInteractionContext);
|
||||
const verificationId = verificationIdsMap[VerificationType.Social];
|
||||
|
||||
if (!is(state, socialAccountNotExistErrorDataGuard)) {
|
||||
return <ErrorPage rawMessage="Missing relate account info" />;
|
||||
|
@ -45,11 +49,19 @@ const SocialLinkAccount = () => {
|
|||
return <ErrorPage rawMessage="Connector not found" />;
|
||||
}
|
||||
|
||||
if (!verificationId) {
|
||||
return <ErrorPage title="error.invalid_session" rawMessage="Verification id not found" />;
|
||||
}
|
||||
|
||||
const { relatedUser } = state;
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout title={getPageTitle(signUpMethods)}>
|
||||
<SocialLinkAccountContainer connectorId={connectorId} relatedUser={relatedUser} />
|
||||
<SocialLinkAccountContainer
|
||||
connectorId={connectorId}
|
||||
verificationId={verificationId}
|
||||
relatedUser={relatedUser}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<string, unknown>) => {
|
||||
async (connectorId: string, verificationId: string, data: Record<string, unknown>) => {
|
||||
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 };
|
||||
|
|
|
@ -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<ErrorHandlers>(
|
||||
() => ({
|
||||
// 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<string, unknown>) => {
|
||||
// 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<string, unknown>) => {
|
||||
// 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 };
|
||||
|
|
|
@ -63,7 +63,7 @@ const VerificationCode = () => {
|
|||
// VerificationId not found
|
||||
const verificationId = verificationIdsMap[codeVerificationTypeMap[type]];
|
||||
if (!verificationId) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
return <ErrorPage title="error.invalid_session" rawMessage="Verification id not found" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
Loading…
Add table
Reference in a new issue