0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(experience): use button loading for social sign-in (#6316)

This commit is contained in:
Xiao Yijun 2024-07-24 13:02:09 +08:00 committed by GitHub
parent 52c0dccbc7
commit 74e41e854b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 85 additions and 16 deletions

View file

@ -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<string>();
const handleClick = async (connector: ExperienceSocialConnector) => {
setLoadingConnectorId(connector.id);
await invokeSocialSignIn(connector);
setLoadingConnectorId(undefined);
};
return (
<div className={classNames(styles.socialLinkList, className)}>
{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);
}}
/>
);

View file

@ -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 {

View file

@ -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.
*/
if (shouldClearInteractionContextSession) {
clearInteractionContextSessionStorage();
}
/**
* Perform the actual redirect
* This is a synchronous operation and will immediately unload the current page
*/
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<never>(noop);
},
[clearInteractionContextSessionStorage, setLoading]
[
clearInteractionContextSessionStorage,
isReplace,
setLoading,
shouldClearInteractionContextSession,
]
);
return redirectTo;

View file

@ -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]
);
};

View file

@ -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<string>();
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);
}}
/>
);
})}