Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/experience/src/containers/SocialSignInList/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) => {
Expand All @@ -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);
}}
/>
);
Expand Down
16 changes: 14 additions & 2 deletions packages/experience/src/containers/SocialSignInList/use-social.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 36 additions & 6 deletions packages/experience/src/hooks/use-global-redirect-to.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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
Expand All @@ -43,7 +68,12 @@ function useGlobalRedirectTo() {
*/
return new Promise<never>(noop);
},
[clearInteractionContextSessionStorage, setLoading]
[
clearInteractionContextSessionStorage,
isReplace,
setLoading,
shouldClearInteractionContextSession,
]
);

return redirectTo;
Expand Down
16 changes: 11 additions & 5 deletions packages/experience/src/hooks/use-single-sign-on.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`
Expand All @@ -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]
);
};

Expand Down
15 changes: 13 additions & 2 deletions packages/experience/src/pages/SingleSignOnConnectors/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();

Expand Down Expand Up @@ -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);
}}
/>
);
})}
Expand Down
Loading