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:
parent
52c0dccbc7
commit
74e41e854b
5 changed files with 85 additions and 16 deletions
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<never>(noop);
|
||||
},
|
||||
[clearInteractionContextSessionStorage, setLoading]
|
||||
[
|
||||
clearInteractionContextSessionStorage,
|
||||
isReplace,
|
||||
setLoading,
|
||||
shouldClearInteractionContextSession,
|
||||
]
|
||||
);
|
||||
|
||||
return redirectTo;
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
Loading…
Reference in a new issue