From 65059565e6cb52470a981fc50629d02870970acd Mon Sep 17 00:00:00 2001 From: simeng-li Date: Tue, 19 Apr 2022 16:32:33 +0800 Subject: [PATCH] refactor(ui): refactor social login hooks (#570) * feat(ui): adjust toast style adjust toast style * refactor(ui): refactor social hooks refactor social hooks * fix(ui): fix ci issue fix ci issue * fix(ui): cr fix cr fix * fix(ui): fix social sign-in flow fix social sign-in flow --- packages/ui/src/App.tsx | 1 + packages/ui/src/__mocks__/ContextProvider.tsx | 19 ++ packages/ui/src/apis/index.test.ts | 6 +- packages/ui/src/apis/social.ts | 2 +- .../ui/src/components/Toast/index.module.scss | 3 +- .../SocialSignIn/PrimarySocialSignIn.tsx | 3 +- .../SecondarySocialSignIn.test.tsx | 114 +++++++++- .../SocialSignIn/SecondarySocialSignIn.tsx | 2 +- packages/ui/src/hooks/use-social-connector.ts | 87 -------- packages/ui/src/hooks/use-social.ts | 205 ++++++++++++++++++ packages/ui/src/include.d/global.d.ts | 10 + .../ui/src/pages/Callback/index.module.scss | 10 + packages/ui/src/pages/Callback/index.tsx | 15 +- packages/ui/src/utils/index.test.ts | 7 +- packages/ui/src/utils/index.ts | 9 +- 15 files changed, 389 insertions(+), 104 deletions(-) create mode 100644 packages/ui/src/__mocks__/ContextProvider.tsx delete mode 100644 packages/ui/src/hooks/use-social-connector.ts create mode 100644 packages/ui/src/hooks/use-social.ts create mode 100644 packages/ui/src/include.d/global.d.ts create mode 100644 packages/ui/src/pages/Callback/index.module.scss diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 944158922..1d753db77 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -50,6 +50,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/ui/src/__mocks__/ContextProvider.tsx b/packages/ui/src/__mocks__/ContextProvider.tsx new file mode 100644 index 000000000..21bc39111 --- /dev/null +++ b/packages/ui/src/__mocks__/ContextProvider.tsx @@ -0,0 +1,19 @@ +import React, { useState, useMemo } from 'react'; + +import PageContext from '@/hooks/page-context'; +import { SignInExperienceSettings } from '@/types'; + +const ContextProvider = ({ children }: { children: React.ReactNode }) => { + const [loading, setLoading] = useState(false); + const [toast, setToast] = useState(''); + const [experienceSettings, setExperienceSettings] = useState(); + + const context = useMemo( + () => ({ loading, setLoading, toast, setToast, experienceSettings, setExperienceSettings }), + [experienceSettings, loading, toast] + ); + + return {children}; +}; + +export default ContextProvider; diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts index 1b59e9a75..2d84def80 100644 --- a/packages/ui/src/apis/index.test.ts +++ b/packages/ui/src/apis/index.test.ts @@ -17,7 +17,7 @@ import { } from './sign-in'; import { invokeSocialSignIn, - signInWithSoical, + signInWithSocial, bindSocialAccount, registerWithSocial, } from './social'; @@ -153,14 +153,14 @@ describe('api', () => { }); }); - it('signInWithSoical', async () => { + it('signInWithSocial', async () => { const parameters = { connectorId: 'connectorId', state: 'state', redirectUri: 'redirectUri', code: 'code', }; - await signInWithSoical(parameters); + await signInWithSocial(parameters); expect(ky.post).toBeCalledWith('/api/session/sign-in/social', { json: parameters, }); diff --git a/packages/ui/src/apis/social.ts b/packages/ui/src/apis/social.ts index e8661f388..b966c416b 100644 --- a/packages/ui/src/apis/social.ts +++ b/packages/ui/src/apis/social.ts @@ -20,7 +20,7 @@ export const invokeSocialSignIn = async ( .json(); }; -export const signInWithSoical = async (parameters: { +export const signInWithSocial = async (parameters: { connectorId: string; state: string; redirectUri: string; diff --git a/packages/ui/src/components/Toast/index.module.scss b/packages/ui/src/components/Toast/index.module.scss index 532e7a9ee..e609dbc29 100644 --- a/packages/ui/src/components/Toast/index.module.scss +++ b/packages/ui/src/components/Toast/index.module.scss @@ -11,16 +11,17 @@ pointer-events: none; .toast { + max-width: 360px; padding: _.unit(2) _.unit(4); font: var(--font-body-medium); color: var(--color-font-toast-text); border-radius: _.unit(2); - max-width: none; background: var(--color-dark-background); min-width: _.unit(25); text-align: center; opacity: 0%; transition: opacity 0.3s ease-in-out; + word-break: break-word; &[data-visible='true'] { opacity: 100%; diff --git a/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.tsx b/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.tsx index 162fb44b5..2a235d6e7 100644 --- a/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.tsx +++ b/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.tsx @@ -4,7 +4,7 @@ import React, { useState, useMemo } from 'react'; import SocialLinkButton from '@/components/Button/SocialLinkButton'; import { ExpandMoreIcon } from '@/components/Icons'; -import useSocial from '@/hooks/use-social-connector'; +import useSocial from '@/hooks/use-social'; import * as styles from './index.module.scss'; @@ -42,7 +42,6 @@ const PrimarySocialSignIn = ({ className, connectors, isPopup = false }: Props) ))} {!displayAll && ( { setShowAll(true); }} diff --git a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx index 711712915..32f149ddf 100644 --- a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx +++ b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx @@ -1,12 +1,40 @@ -import { render } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import ContextProvider from '@/__mocks__/ContextProvider'; import { socialConnectors } from '@/__mocks__/logto'; +import * as socialSignInApi from '@/apis/social'; +import { generateState, storeState } from '@/hooks/use-social'; import SecondarySocialSignIn from './SecondarySocialSignIn'; describe('SecondarySocialSignIn', () => { + const mockOrigin = 'https://logto.dev'; + + const invokeSocialSignInSpy = jest + .spyOn(socialSignInApi, 'invokeSocialSignIn') + .mockResolvedValue({ redirectTo: `${mockOrigin}/callback` }); + + const signInWithSocialSpy = jest + .spyOn(socialSignInApi, 'signInWithSocial') + .mockResolvedValue({ redirectTo: `${mockOrigin}/callback` }); + + beforeEach(() => { + /* eslint-disable @silverhand/fp/no-mutation */ + // @ts-expect-error mock global object + globalThis.logtoNativeSdk = { + platform: 'web', + getPostMessage: jest.fn(() => jest.fn()), + callbackUriScheme: '/logto:', + }; + /* eslint-enable @silverhand/fp/no-mutation */ + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('less than four connectors', () => { const { container } = render( @@ -24,4 +52,86 @@ describe('SecondarySocialSignIn', () => { ); expect(container.querySelectorAll('button')).toHaveLength(4); }); + + it('invoke web social signIn', async () => { + const connectors = socialConnectors.slice(0, 1); + + const { container } = render( + + + + + + ); + const socialButton = container.querySelector('button'); + + if (socialButton) { + await waitFor(() => { + fireEvent.click(socialButton); + }); + + expect(invokeSocialSignInSpy).toBeCalled(); + } + }); + + it('invoke native social signIn', async () => { + /* eslint-disable @silverhand/fp/no-mutation */ + // @ts-expect-error mock global object + logtoNativeSdk.platform = 'ios'; + /* eslint-enable @silverhand/fp/no-mutation */ + + const connectors = socialConnectors.slice(0, 1); + const { container } = render( + + + + + + ); + const socialButton = container.querySelector('button'); + + if (socialButton) { + await waitFor(() => { + fireEvent.click(socialButton); + }); + + expect(invokeSocialSignInSpy).toBeCalled(); + expect(logtoNativeSdk?.getPostMessage).toBeCalled(); + } + }); + + it('callback validation and signIn with social', async () => { + const connectors = socialConnectors.slice(0, 1); + + const state = generateState(); + storeState(state, 'github'); + + /* eslint-disable @silverhand/fp/no-mutating-methods */ + Object.defineProperty(window, 'location', { + value: { + href: `/sign-in/callback?state=${state}&code=foo`, + search: `?state=${state}&code=foo`, + pathname: '/sign-in/callback', + assign: jest.fn(), + }, + }); + /* eslint-enable @silverhand/fp/no-mutating-methods */ + + render( + + + + } + /> + + + + ); + + await waitFor(() => { + expect(signInWithSocialSpy).toBeCalled(); + }); + }); }); diff --git a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx index 90fb190ed..220b2c4f9 100644 --- a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx +++ b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx @@ -4,7 +4,7 @@ import React, { useMemo } from 'react'; import MoreButton from '@/components/Button/MoreButton'; import SocialIconButton from '@/components/Button/SocialIconButton'; -import useSocial from '@/hooks/use-social-connector'; +import useSocial from '@/hooks/use-social'; import * as styles from './index.module.scss'; diff --git a/packages/ui/src/hooks/use-social-connector.ts b/packages/ui/src/hooks/use-social-connector.ts deleted file mode 100644 index 0f94448b0..000000000 --- a/packages/ui/src/hooks/use-social-connector.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect, useCallback } from 'react'; -import { useLocation } from 'react-router-dom'; - -import { invokeSocialSignIn, signInWithSoical } from '@/apis/social'; -import { generateRandomString } from '@/utils'; - -import useApi from './use-api'; - -const storageKeyPrefix = 'social_auth_state'; -const webPlatformPrefix = 'web'; -const mobilePlatformPrefix = 'mobile'; - -const isMobileWebview = () => { - // TODO: read from native sdk embedded params - return true; -}; - -const useSocial = () => { - const { result: invokeSocialSignInResult, run: asyncSignInWithSocial } = - useApi(invokeSocialSignIn); - const { result: signInToSocialResult, run: asyncSignInWithSoical } = useApi(signInWithSoical); - - const { search } = useLocation(); - - const validateState = useCallback((state: string, connectorId: string) => { - if (state.startsWith(mobilePlatformPrefix)) { - return true; // Not able to validate the state source from the native call stack - } - - const stateStorage = sessionStorage.getItem(`${storageKeyPrefix}:${connectorId}`); - - return stateStorage === state; - }, []); - - const signInWithSocialHandler = useCallback( - (connector?: string) => { - const uriParameters = new URLSearchParams(search); - const state = uriParameters.get('state'); - const code = uriParameters.get('code'); - - if (!state || !code || !connector) { - // TODO: error message - return; - } - - if (!validateState(state, connector)) { - // TODO: error message - return; - } - - void asyncSignInWithSoical({ connectorId: connector, state, code, redirectUri: 'TODO' }); - }, - [asyncSignInWithSoical, search, validateState] - ); - - const invokeSocialSignInHandler = useCallback( - async (connectorId: string) => { - const state = `${ - isMobileWebview() ? mobilePlatformPrefix : webPlatformPrefix - }_${generateRandomString()}`; - const { origin } = window.location; - sessionStorage.setItem(`${storageKeyPrefix}:${connectorId}`, state); - - return asyncSignInWithSocial(connectorId, state, `${origin}/callback/${connectorId}`); - }, - [asyncSignInWithSocial] - ); - - useEffect(() => { - if (invokeSocialSignInResult?.redirectTo) { - window.location.assign(invokeSocialSignInResult.redirectTo); - } - }, [invokeSocialSignInResult]); - - useEffect(() => { - if (signInToSocialResult?.redirectTo) { - window.location.assign(signInToSocialResult.redirectTo); - } - }, [signInToSocialResult]); - - return { - invokeSocialSignIn: invokeSocialSignInHandler, - signInWithSocial: signInWithSocialHandler, - }; -}; - -export default useSocial; diff --git a/packages/ui/src/hooks/use-social.ts b/packages/ui/src/hooks/use-social.ts new file mode 100644 index 000000000..146e6b616 --- /dev/null +++ b/packages/ui/src/hooks/use-social.ts @@ -0,0 +1,205 @@ +import { useEffect, useCallback, useContext } from 'react'; +import { useParams } from 'react-router-dom'; + +import { invokeSocialSignIn, signInWithSocial } from '@/apis/social'; +import { generateRandomString, parseQueryParameters } from '@/utils'; + +import PageContext from './page-context'; +import useApi from './use-api'; + +/** + * Social Connector State Utility Methods + * @param state + * @param state.uuid - unique id + * @param state.platform - platform + * @param state.callbackUriScheme - callback uri scheme + */ + +type State = { + uuid: string; + platform: 'web' | 'ios' | 'android'; + callbackUriScheme?: string; +}; + +const storageKeyPrefix = 'social_auth_state'; + +const getLogtoNativeSdk = () => { + if (typeof logtoNativeSdk !== 'undefined') { + return logtoNativeSdk; + } +}; + +export const generateState = () => { + const uuid = generateRandomString(); + const platform = getLogtoNativeSdk()?.platform ?? 'web'; + const callbackUriScheme = getLogtoNativeSdk()?.callbackUriScheme; + + const state: State = { uuid, platform, callbackUriScheme }; + + return btoa(JSON.stringify(state)); +}; + +export const decodeState = (state: string) => { + try { + return JSON.parse(atob(state)) as State; + } catch {} +}; + +export const stateValidation = (state: string, connectorId: string) => { + const stateStorage = sessionStorage.getItem(`${storageKeyPrefix}:${connectorId}`); + + return stateStorage === state; +}; + +export const storeState = (state: string, connectorId: string) => { + sessionStorage.setItem(`${storageKeyPrefix}:${connectorId}`, state); +}; + +/* ============================================================================ */ + +const isNativeWebview = () => { + const platform = getLogtoNativeSdk()?.platform ?? ''; + + return ['ios', 'android'].includes(platform); +}; + +const useSocial = () => { + const { setToast } = useContext(PageContext); + const parameters = useParams(); + + const { result: invokeSocialSignInResult, run: asyncInvokeSocialSignIn } = + useApi(invokeSocialSignIn); + + const { result: signInWithSocialResult, run: asyncSignInWithSocial } = useApi(signInWithSocial); + + const invokeSocialSignInHandler = useCallback( + async (connectorId: string) => { + const state = generateState(); + storeState(state, connectorId); + + const { origin } = window.location; + + return asyncInvokeSocialSignIn(connectorId, state, `${origin}/callback/${connectorId}`); + }, + [asyncInvokeSocialSignIn] + ); + + const signInWithSocialHandler = useCallback( + (connectorId: string, state: string, code: string) => { + if (!stateValidation(state, connectorId)) { + // TODO: Invalid state error message + return; + } + void asyncSignInWithSocial({ connectorId, state, code, redirectUri: '' }); + }, + [asyncSignInWithSocial] + ); + + const socialCallbackHandler = useCallback( + (connectorId?: string) => { + const { state, code, error, error_description } = parseQueryParameters( + window.location.search + ); + + if (error) { + setToast(`${error}${error_description ? `: ${error_description}` : ''}`); + } + + if (!state || !code || !connectorId) { + // TODO: error message + return; + } + + const decodedState = decodeState(state); + + if (!decodedState) { + // TODO: invalid state error message + return; + } + + const { platform, callbackUriScheme } = decodedState; + + if (platform === 'web') { + window.location.assign( + new URL(`${location.origin}/sign-in/callback/${connectorId}/${window.location.search}`) + ); + + return; + } + + if (!callbackUriScheme) { + // TODO: native callbackUriScheme not found error message + return; + } + + window.location.assign(new URL(`${callbackUriScheme}${window.location.search}`)); + }, + [setToast] + ); + + // InvokeSocialSignIn Callback + useEffect(() => { + const { redirectTo } = invokeSocialSignInResult ?? {}; + + if (!redirectTo) { + return; + } + + // Invoke Native Social Sign In flow + if (isNativeWebview()) { + getLogtoNativeSdk()?.getPostMessage()({ + callbackUri: redirectTo.replace('/callback', '/sign-in/callback'), + redirectTo, + }); + + return; + } + + // Invoke Web Social Sign In flow + window.location.assign(redirectTo); + }, [invokeSocialSignInResult]); + + // SignInWithSocial Callback + useEffect(() => { + if (signInWithSocialResult?.redirectTo) { + window.location.assign(signInWithSocialResult.redirectTo); + } + }, [signInWithSocialResult]); + + // SignIn Callback Page Handler + useEffect(() => { + if (!location.pathname.includes('/sign-in/callback') || !parameters.connector) { + return; + } + + const { state, code } = parseQueryParameters(window.location.search); + + if (!state || !code) { + return; + } + + signInWithSocialHandler(parameters.connector, state, code); + }, [parameters.connector, signInWithSocialHandler]); + + // Monitor Native Error Message + useEffect(() => { + const nativeMessageHandler = (event: MessageEvent) => { + if (event.origin === window.location.origin) { + setToast(JSON.stringify(event.data)); + } + }; + + window.addEventListener('message', nativeMessageHandler); + + return () => { + window.removeEventListener('message', nativeMessageHandler); + }; + }, [setToast]); + + return { + invokeSocialSignIn: invokeSocialSignInHandler, + socialCallbackHandler, + }; +}; + +export default useSocial; diff --git a/packages/ui/src/include.d/global.d.ts b/packages/ui/src/include.d/global.d.ts new file mode 100644 index 000000000..d8feb9a67 --- /dev/null +++ b/packages/ui/src/include.d/global.d.ts @@ -0,0 +1,10 @@ +// Logto Native SDK + +type LogtoNativeSdkInfo = { + platform: 'ios' | 'android'; + callbackUriScheme: string; + getPostMessage: () => (data: { callbackUri?: string; redirectTo?: string }) => void; + supportedSocialConnectors: string[]; +}; + +declare const logtoNativeSdk: LogtoNativeSdkInfo | undefined; diff --git a/packages/ui/src/pages/Callback/index.module.scss b/packages/ui/src/pages/Callback/index.module.scss new file mode 100644 index 000000000..484c11856 --- /dev/null +++ b/packages/ui/src/pages/Callback/index.module.scss @@ -0,0 +1,10 @@ +.container { + padding: 0; + margin: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} diff --git a/packages/ui/src/pages/Callback/index.tsx b/packages/ui/src/pages/Callback/index.tsx index b5e38d2c8..991293c5e 100644 --- a/packages/ui/src/pages/Callback/index.tsx +++ b/packages/ui/src/pages/Callback/index.tsx @@ -1,7 +1,9 @@ import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import useSocial from '@/hooks/use-social-connector'; +import useSocial from '@/hooks/use-social'; + +import * as styles from './index.module.scss'; type Props = { connector?: string; @@ -9,13 +11,16 @@ type Props = { const Callback = () => { const { connector } = useParams(); - const { signInWithSocial } = useSocial(); + const { socialCallbackHandler } = useSocial(); + // SocialSignIn Callback Handler useEffect(() => { - signInWithSocial(connector); - }, [signInWithSocial, connector]); + if (connector) { + socialCallbackHandler(connector); + } + }, [connector, socialCallbackHandler]); - return
{connector} loading...
; + return
{connector} loading...
; }; export default Callback; diff --git a/packages/ui/src/utils/index.test.ts b/packages/ui/src/utils/index.test.ts index 2c2a77468..bbef40af0 100644 --- a/packages/ui/src/utils/index.test.ts +++ b/packages/ui/src/utils/index.test.ts @@ -1,8 +1,13 @@ -import { generateRandomString } from '.'; +import { generateRandomString, parseQueryParameters } from '.'; describe('util methods', () => { it('generateRandomString', () => { const random = generateRandomString(); expect(random).not.toBeNull(); }); + + it('parseQueryParameters', () => { + const parameters = parseQueryParameters('?foo=test&bar=test2'); + expect(parameters).toEqual({ foo: 'test', bar: 'test2' }); + }); }); diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index e47257af0..1a55d0b52 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -1,4 +1,11 @@ import { fromUint8Array } from 'js-base64'; -export const generateRandomString = (length = 16) => +export const generateRandomString = (length = 8) => fromUint8Array(crypto.getRandomValues(new Uint8Array(length)), true); + +export const parseQueryParameters = (parameters: string | URLSearchParams) => { + const searchParameters = + parameters instanceof URLSearchParams ? parameters : new URLSearchParams(parameters); + + return Object.fromEntries(searchParameters.entries()); +};