From 74e41e854b6d46dd35d84141dcfb4b2741cd10df Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Wed, 24 Jul 2024 13:02:09 +0800 Subject: [PATCH] refactor(experience): use button loading for social sign-in (#6316) --- .../src/containers/SocialSignInList/index.tsx | 12 +++++- .../containers/SocialSignInList/use-social.ts | 16 ++++++- .../src/hooks/use-global-redirect-to.ts | 42 ++++++++++++++++--- .../src/hooks/use-single-sign-on.ts | 16 ++++--- .../pages/SingleSignOnConnectors/index.tsx | 15 ++++++- 5 files changed, 85 insertions(+), 16 deletions(-) diff --git a/packages/experience/src/containers/SocialSignInList/index.tsx b/packages/experience/src/containers/SocialSignInList/index.tsx index 6b99756cc..24c902482 100644 --- a/packages/experience/src/containers/SocialSignInList/index.tsx +++ b/packages/experience/src/containers/SocialSignInList/index.tsx @@ -1,5 +1,6 @@ import type { ExperienceSocialConnector } from '@logto/schemas'; import classNames from 'classnames'; +import { useState } from 'react'; import SocialLinkButton from '@/components/Button/SocialLinkButton'; import useNativeMessageListener from '@/hooks/use-native-message-listener'; @@ -17,6 +18,14 @@ const SocialSignInList = ({ className, socialConnectors = [] }: Props) => { const { invokeSocialSignIn, theme } = useSocial(); useNativeMessageListener(); + const [loadingConnectorId, setLoadingConnectorId] = useState(); + + const handleClick = async (connector: ExperienceSocialConnector) => { + setLoadingConnectorId(connector.id); + await invokeSocialSignIn(connector); + setLoadingConnectorId(undefined); + }; + return (
{socialConnectors.map((connector) => { @@ -29,8 +38,9 @@ const SocialSignInList = ({ className, socialConnectors = [] }: Props) => { name={name} logo={getLogoUrl({ theme, logoUrl, darkLogoUrl })} target={target} + isLoading={loadingConnectorId === id} onClick={() => { - void invokeSocialSignIn(connector); + void handleClick(connector); }} /> ); diff --git a/packages/experience/src/containers/SocialSignInList/use-social.ts b/packages/experience/src/containers/SocialSignInList/use-social.ts index 6e884c55d..3ea08c110 100644 --- a/packages/experience/src/containers/SocialSignInList/use-social.ts +++ b/packages/experience/src/containers/SocialSignInList/use-social.ts @@ -9,6 +9,7 @@ import PageContext from '@/Providers/PageContextProvider/PageContext'; import { getSocialAuthorizationUrl } from '@/apis/interaction'; 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'; @@ -19,6 +20,10 @@ const useSocial = () => { const handleError = useErrorHandler(); const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl); const { termsValidation, agreeToTermsPolicy } = useTerms(); + const redirectTo = useGlobalRedirectTo({ + shouldClearInteractionContextSession: false, + isReplace: false, + }); const nativeSignInHandler = useCallback( (redirectTo: string, connector: ExperienceSocialConnector) => { @@ -76,9 +81,16 @@ const useSocial = () => { } // Invoke web social sign-in flow - window.location.assign(result.redirectTo); + await redirectTo(result.redirectTo); }, - [agreeToTermsPolicy, asyncInvokeSocialSignIn, handleError, nativeSignInHandler, termsValidation] + [ + agreeToTermsPolicy, + asyncInvokeSocialSignIn, + handleError, + nativeSignInHandler, + redirectTo, + termsValidation, + ] ); return { diff --git a/packages/experience/src/hooks/use-global-redirect-to.ts b/packages/experience/src/hooks/use-global-redirect-to.ts index 168d052d7..ebba12dc1 100644 --- a/packages/experience/src/hooks/use-global-redirect-to.ts +++ b/packages/experience/src/hooks/use-global-redirect-to.ts @@ -4,17 +4,36 @@ import { useCallback, useContext } from 'react'; import PageContext from '@/Providers/PageContextProvider/PageContext'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +type Options = { + /** + * Whether to clear the interaction context session storage before redirecting. + * Defaults to `true`. + * Set to `false` if this redirection is not the final redirection in the sign-in process (not the sign-in successful redirection). + */ + shouldClearInteractionContextSession?: boolean; + /** + * Whether to use `window.location.replace` instead of `window.location.assign` for the redirection. + * Defaults to `true`. + * Use `false` if this is a 3rd-party URL redirection (social sign-in or single sign-on redirection) that should be added to the browser history. + */ + isReplace?: boolean; +}; + /** - * Hook for global redirection after successful login. + * Hook for global redirection to 3rd-party URLs (e.g., social sign-in, single sign-on, and the final redirection to the + * app redirect URI after successful sign-in). * * This hook provides an async function that represents the final step in the login process. - * It sets a global loading state, clears the interaction context, and then redirects the user. + * It sets a global loading state, clears the interaction context (if needed), and then redirects the user. * * The returned async function will never resolve, as the page will be redirected before * the Promise can settle. This behavior is intentional and serves to maintain the loading * state on the interaction element (e.g., a button) that triggered the successful login. */ -function useGlobalRedirectTo() { +function useGlobalRedirectTo({ + shouldClearInteractionContextSession = true, + isReplace = true, +}: Options = {}) { const { setLoading } = useContext(PageContext); const { clearInteractionContextSessionStorage } = useContext(UserInteractionContext); @@ -29,12 +48,18 @@ function useGlobalRedirectTo() { * Clear all identifier input values from the storage once the interaction is submitted. * The Identifier cache should be session-isolated, so it should be cleared after the interaction is completed. */ - clearInteractionContextSessionStorage(); + if (shouldClearInteractionContextSession) { + clearInteractionContextSessionStorage(); + } /** * Perform the actual redirect * This is a synchronous operation and will immediately unload the current page */ - window.location.replace(url); + if (isReplace) { + window.location.replace(url); + } else { + window.location.assign(url); + } /** * Return a Promise that never resolves @@ -43,7 +68,12 @@ function useGlobalRedirectTo() { */ return new Promise(noop); }, - [clearInteractionContextSessionStorage, setLoading] + [ + clearInteractionContextSessionStorage, + isReplace, + setLoading, + shouldClearInteractionContextSession, + ] ); return redirectTo; diff --git a/packages/experience/src/hooks/use-single-sign-on.ts b/packages/experience/src/hooks/use-single-sign-on.ts index 751ba725f..a6b62109c 100644 --- a/packages/experience/src/hooks/use-single-sign-on.ts +++ b/packages/experience/src/hooks/use-single-sign-on.ts @@ -6,9 +6,15 @@ import useErrorHandler from '@/hooks/use-error-handler'; import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk'; import { buildSocialLandingUri, generateState, storeState } from '@/utils/social-connectors'; +import useGlobalRedirectTo from './use-global-redirect-to'; + const useSingleSignOn = () => { const handleError = useErrorHandler(); const asyncInvokeSingleSignOn = useApi(getSingleSignOnUrl); + const redirectTo = useGlobalRedirectTo({ + shouldClearInteractionContextSession: false, + isReplace: false, + }); /** * Native IdP Sign In Flow @@ -39,7 +45,7 @@ const useSingleSignOn = () => { const state = generateState(); storeState(state, connectorId); - const [error, redirectTo] = await asyncInvokeSingleSignOn( + const [error, redirectUrl] = await asyncInvokeSingleSignOn( connectorId, state, `${window.location.origin}/callback/${connectorId}` @@ -51,19 +57,19 @@ const useSingleSignOn = () => { return; } - if (!redirectTo) { + if (!redirectUrl) { return; } // Invoke Native Sign In flow if (isNativeWebview()) { - nativeSignInHandler(redirectTo, connectorId); + nativeSignInHandler(redirectUrl, connectorId); } // Invoke Web Sign In flow - window.location.assign(redirectTo); + await redirectTo(redirectUrl); }, - [asyncInvokeSingleSignOn, handleError, nativeSignInHandler] + [asyncInvokeSingleSignOn, handleError, nativeSignInHandler, redirectTo] ); }; diff --git a/packages/experience/src/pages/SingleSignOnConnectors/index.tsx b/packages/experience/src/pages/SingleSignOnConnectors/index.tsx index c1b0904a6..fb17d5b59 100644 --- a/packages/experience/src/pages/SingleSignOnConnectors/index.tsx +++ b/packages/experience/src/pages/SingleSignOnConnectors/index.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; @@ -17,6 +17,14 @@ const SingleSignOnConnectors = () => { const navigate = useNavigate(); const onSubmit = useSingleSignOn(); + const [loadingConnectorId, setLoadingConnectorId] = useState(); + + const handleSubmit = async (connectorId: string) => { + setLoadingConnectorId(connectorId); + await onSubmit(connectorId); + setLoadingConnectorId(undefined); + }; + // Listen to native message useNativeMessageListener(); @@ -46,7 +54,10 @@ const SingleSignOnConnectors = () => { name={{ en: connectorName }} // I18n support for connectorName not supported yet, always display the plain text logo={getLogoUrl({ theme, logoUrl, darkLogoUrl })} target={connectorName} - onClick={async () => onSubmit(id)} + isLoading={loadingConnectorId === connector.id} + onClick={() => { + void handleSubmit(connector.id); + }} /> ); })}