diff --git a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx index 3593acc3c..08e8eb449 100644 --- a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx +++ b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx @@ -6,7 +6,7 @@ import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { socialConnectors, mockSignInExperienceSettings } from '@/__mocks__/logto'; import * as socialSignInApi from '@/apis/social'; -import { generateState, storeState } from '@/hooks/use-social'; +import { generateState, storeState } from '@/hooks/utils'; import SecondarySocialSignIn, { defaultSize } from './SecondarySocialSignIn'; diff --git a/packages/ui/src/hooks/use-social.ts b/packages/ui/src/hooks/use-social.ts index eac819f36..1a6713f31 100644 --- a/packages/ui/src/hooks/use-social.ts +++ b/packages/ui/src/hooks/use-social.ts @@ -3,67 +3,20 @@ import { useTranslation } from 'react-i18next'; import { useParams, useNavigate } from 'react-router-dom'; import { invokeSocialSignIn, signInWithSocial } from '@/apis/social'; -import { generateRandomString, parseQueryParameters } from '@/utils'; +import { parseQueryParameters } from '@/utils'; import useApi, { ErrorHandlers } from './use-api'; import { PageContext } from './use-page-context'; import useTerms from './use-terms'; - -/** - * Social Connector State Utility Methods - * @param state - * @param state.uuid - unique id - * @param state.platform - platform - * @param state.callbackLink - callback uri scheme - */ - -type State = { - uuid: string; - platform: 'web' | 'ios' | 'android'; - callbackLink?: 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 callbackLink = getLogtoNativeSdk()?.callbackLink; - - const state: State = { uuid, platform, callbackLink }; - - 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); -}; +import { + getLogtoNativeSdk, + isNativeWebview, + generateState, + decodeState, + stateValidation, + storeState, + filterSocialConnectors, +} from './utils'; const useSocial = () => { const { setToast, experienceSettings } = useContext(PageContext); @@ -87,13 +40,9 @@ const useSocial = () => { [navigate, parameters.connector] ); - // Filter native supported social connectors const socialConnectors = useMemo( - () => - (experienceSettings?.socialConnectors ?? []).filter(({ id }) => { - return !isNativeWebview() || getLogtoNativeSdk()?.supportedSocialConnectorIds.includes(id); - }), - [experienceSettings?.socialConnectors] + () => filterSocialConnectors(experienceSettings?.socialConnectors), + [experienceSettings] ); const { run: asyncInvokeSocialSignIn } = useApi(invokeSocialSignIn); diff --git a/packages/ui/src/hooks/utils.test.ts b/packages/ui/src/hooks/utils.test.ts new file mode 100644 index 000000000..dd4d65d5a --- /dev/null +++ b/packages/ui/src/hooks/utils.test.ts @@ -0,0 +1,45 @@ +import { ConnectorData } from '@/types'; + +import { filterSocialConnectors } from './utils'; + +const mockConnectors = [ + { platform: 'Web', target: 'facebook' }, + { platform: 'Web', target: 'google' }, + { platform: 'Universal', target: 'facebook' }, + { platform: 'Universal', target: 'WeChat' }, + { platform: 'Native', target: 'WeChat' }, + { platform: 'Native', target: 'Alipay' }, +] as ConnectorData[]; + +describe('filterSocialConnectors', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('undefined input should return empty list', () => { + expect(filterSocialConnectors()).toEqual([]); + }); + + it('filter web connectors', () => { + expect(filterSocialConnectors(mockConnectors)).toEqual([ + { platform: 'Web', target: 'facebook' }, + { platform: 'Web', target: 'google' }, + { platform: 'Universal', target: 'WeChat' }, + ]); + }); + + it('filter Native connectors', () => { + /* eslint-disable @silverhand/fp/no-mutation */ + // @ts-expect-error mock global object + globalThis.logtoNativeSdk = { + platform: 'ios', + supportedSocialConnectorIds: ['Web', 'WeChat'], + }; + /* eslint-enable @silverhand/fp/no-mutation */ + + expect(filterSocialConnectors(mockConnectors)).toEqual([ + { platform: 'Universal', target: 'facebook' }, + { platform: 'Native', target: 'WeChat' }, + ]); + }); +}); diff --git a/packages/ui/src/hooks/utils.ts b/packages/ui/src/hooks/utils.ts new file mode 100644 index 000000000..8f2f7a2fd --- /dev/null +++ b/packages/ui/src/hooks/utils.ts @@ -0,0 +1,120 @@ +import { ConnectorData } from '@/types'; +import { generateRandomString } from '@/utils'; + +/** + * Native SDK Utility Methods + */ +export const getLogtoNativeSdk = () => { + if (typeof logtoNativeSdk !== 'undefined') { + return logtoNativeSdk; + } +}; + +export const isNativeWebview = () => { + const platform = getLogtoNativeSdk()?.platform ?? ''; + + return ['ios', 'android'].includes(platform); +}; + +/** + * Social Connector State Utility Methods + * @param state + * @param state.uuid - unique id + * @param state.platform - platform + * @param state.callbackLink - callback uri scheme + */ + +type State = { + uuid: string; + platform: 'web' | 'ios' | 'android'; + callbackLink?: string; +}; + +const storageKeyPrefix = 'social_auth_state'; + +export const generateState = () => { + const uuid = generateRandomString(); + const platform = getLogtoNativeSdk()?.platform ?? 'web'; + const callbackLink = getLogtoNativeSdk()?.callbackLink; + + const state: State = { uuid, platform, callbackLink }; + + 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); +}; + +/** + * Social Connectors Filter Utility Methods + */ +export const filterSocialConnectors = (socialConnectors?: ConnectorData[]) => { + if (!socialConnectors) { + return []; + } + + const connectorMap = new Map(); + + if (!isNativeWebview()) { + for (const connector of socialConnectors) { + const { platform, target } = connector; + + if (platform === 'Native') { + continue; + } + + /** + * Accepts both web and universal platform connectors. + * Insert universal connectors only if there is no web platform connector provided with the same target. + * Web platform has higher priority. + **/ + if (platform === 'Web' || !connectorMap.get(target)) { + connectorMap.set(target, connector); + continue; + } + } + + return Array.from(connectorMap.values()); + } + + for (const connector of socialConnectors) { + const { platform, target } = connector; + + if (platform === 'Web') { + continue; + } + + /** + * Accepts both Native and universal platform connectors. + * Insert universal connectors only if there is no Native platform connector provided with the same target. + * Native platform has higher priority. + **/ + if ( + platform === 'Native' && + getLogtoNativeSdk()?.supportedSocialConnectorIds.includes(target) + ) { + connectorMap.set(target, connector); + continue; + } + + if (platform === 'Universal' && !connectorMap.get(target)) { + connectorMap.set(target, connector); + continue; + } + } + + return Array.from(connectorMap.values()); +}; diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index bfdfc5aa3..ab186bced 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -1,4 +1,10 @@ -import { Branding, LanguageInfo, TermsOfUse, ConnectorMetadata } from '@logto/schemas'; +import { + Branding, + LanguageInfo, + TermsOfUse, + ConnectorMetadata, + ConnectorPlatform, +} from '@logto/schemas'; export type UserFlow = 'sign-in' | 'register'; export type SignInMethod = 'username' | 'email' | 'sms' | 'social'; @@ -10,6 +16,7 @@ export enum SearchParameters { export interface ConnectorData extends ConnectorMetadata { id: string; + platform: ConnectorPlatform; } export type SignInExperienceSettings = {