diff --git a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx
index 9eee590b6..e5e010458 100644
--- a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx
+++ b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx
@@ -1,13 +1,14 @@
-import { render, fireEvent, waitFor } from '@testing-library/react';
+import { fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
-import { socialConnectors } from '@/__mocks__/logto';
+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 SecondarySocialSignIn from './SecondarySocialSignIn';
+import SecondarySocialSignIn, { defaultSize } from './SecondarySocialSignIn';
describe('SecondarySocialSignIn', () => {
const mockOrigin = 'https://logto.dev';
@@ -27,6 +28,7 @@ describe('SecondarySocialSignIn', () => {
platform: 'web',
getPostMessage: jest.fn(() => jest.fn()),
callbackLink: '/logto:',
+ supportedSocialConnectors: socialConnectors.map(({ id }) => id),
};
/* eslint-enable @silverhand/fp/no-mutation */
});
@@ -36,21 +38,30 @@ describe('SecondarySocialSignIn', () => {
});
it('less than four connectors', () => {
- const { container } = render(
-
-
-
+ const { container } = renderWithPageContext(
+
+
+
+
+
);
- expect(container.querySelectorAll('button')).toHaveLength(3);
+ expect(container.querySelectorAll('button')).toHaveLength(defaultSize - 1);
});
it('more than four connectors', () => {
- const { container } = render(
-
-
-
+ const { container } = renderWithPageContext(
+
+
+
+
+
);
- expect(container.querySelectorAll('button')).toHaveLength(3);
+ expect(container.querySelectorAll('button')).toHaveLength(defaultSize - 1);
expect(container.querySelector('svg')).not.toBeNull();
});
@@ -58,9 +69,17 @@ describe('SecondarySocialSignIn', () => {
const connectors = socialConnectors.slice(0, 1);
const { container } = renderWithPageContext(
-
-
-
+
+
+
+
+
);
const socialButton = container.querySelector('button');
@@ -81,9 +100,17 @@ describe('SecondarySocialSignIn', () => {
const connectors = socialConnectors.slice(0, 1);
const { container } = renderWithPageContext(
-
-
-
+
+
+
+
+
);
const socialButton = container.querySelector('button');
@@ -98,8 +125,6 @@ describe('SecondarySocialSignIn', () => {
});
it('callback validation and signIn with social', async () => {
- const connectors = socialConnectors.slice(0, 1);
-
const state = generateState();
storeState(state, 'github');
@@ -115,14 +140,13 @@ describe('SecondarySocialSignIn', () => {
/* eslint-enable @silverhand/fp/no-mutating-methods */
renderWithPageContext(
-
-
- }
- />
-
-
+
+
+
+ } />
+
+
+
);
await waitFor(() => {
diff --git a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx
index e51fd652f..8e0bfbfd1 100644
--- a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx
+++ b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx
@@ -1,47 +1,63 @@
-import { ConnectorMetadata } from '@logto/schemas';
import classNames from 'classnames';
-import React, { useMemo } from 'react';
+import React, { useMemo, useState } from 'react';
import SocialIconButton from '@/components/Button/SocialIconButton';
import MoreSocialIcon from '@/components/Icons/MoreSocialIcon';
import useSocial from '@/hooks/use-social';
+import SocialSignInPopUp from './SocialSignInPopUp';
import * as styles from './index.module.scss';
+export const defaultSize = 4;
+
type Props = {
className?: string;
- connectors: Array
>;
- showMoreConnectors?: () => void;
};
-const SecondarySocialSignIn = ({ className, connectors, showMoreConnectors }: Props) => {
- const { invokeSocialSignIn } = useSocial();
- const isOverSize = connectors.length > 4;
+const SecondarySocialSignIn = ({ className }: Props) => {
+ const { socialConnectors, invokeSocialSignIn } = useSocial();
+ const isOverSize = socialConnectors.length > defaultSize;
+ const [showModal, setShowModal] = useState(false);
const displayConnectors = useMemo(() => {
if (isOverSize) {
- return connectors.slice(0, 3);
+ return socialConnectors.slice(0, defaultSize - 1);
}
- return connectors;
- }, [connectors, isOverSize]);
+ return socialConnectors;
+ }, [socialConnectors, isOverSize]);
return (
-
- {displayConnectors.map((connector) => (
-
{
- void invokeSocialSignIn(connector.id);
+ <>
+
+ {displayConnectors.map((connector) => (
+ {
+ void invokeSocialSignIn(connector.id);
+ }}
+ />
+ ))}
+ {isOverSize && (
+ {
+ setShowModal(true);
+ }}
+ />
+ )}
+
+ {isOverSize && (
+ {
+ setShowModal(false);
}}
/>
- ))}
- {isOverSize && (
-
)}
-
+ >
);
};
diff --git a/packages/ui/src/containers/SocialSignIn/SocialSignInPopUp.tsx b/packages/ui/src/containers/SocialSignIn/SocialSignInPopUp.tsx
index 15581bd08..842574652 100644
--- a/packages/ui/src/containers/SocialSignIn/SocialSignInPopUp.tsx
+++ b/packages/ui/src/containers/SocialSignIn/SocialSignInPopUp.tsx
@@ -1,4 +1,3 @@
-import { ConnectorMetadata } from '@logto/schemas';
import React from 'react';
import Drawer from '@/components/Drawer';
@@ -9,12 +8,11 @@ type Props = {
isOpen?: boolean;
onClose: () => void;
className?: string;
- connectors: Array>;
};
-const SocialSignInPopUp = ({ isOpen = false, onClose, className, connectors }: Props) => (
+const SocialSignInPopUp = ({ isOpen = false, onClose, className }: Props) => (
-
+
);
diff --git a/packages/ui/src/containers/UsernameSignin/index.module.scss b/packages/ui/src/containers/UsernameSignin/index.module.scss
index c2a2ee3bd..e6388d7cd 100644
--- a/packages/ui/src/containers/UsernameSignin/index.module.scss
+++ b/packages/ui/src/containers/UsernameSignin/index.module.scss
@@ -15,6 +15,6 @@
}
.terms {
- margin: _.unit(6) 0;
+ margin: _.unit(8) 0 _.unit(4);
}
}
diff --git a/packages/ui/src/hooks/use-social.ts b/packages/ui/src/hooks/use-social.ts
index 10a272944..c2cca5180 100644
--- a/packages/ui/src/hooks/use-social.ts
+++ b/packages/ui/src/hooks/use-social.ts
@@ -1,4 +1,4 @@
-import { useEffect, useCallback, useContext } from 'react';
+import { useEffect, useCallback, useContext, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { invokeSocialSignIn, signInWithSocial } from '@/apis/social';
@@ -22,6 +22,10 @@ type State = {
callbackLink?: string;
};
+type Options = {
+ onSocialSignInCallback?: () => void;
+};
+
const storageKeyPrefix = 'social_auth_state';
const getLogtoNativeSdk = () => {
@@ -64,11 +68,20 @@ const isNativeWebview = () => {
return ['ios', 'android'].includes(platform);
};
-const useSocial = () => {
- const { setToast } = useContext(PageContext);
+const useSocial = (options?: Options) => {
+ const { setToast, experienceSettings } = useContext(PageContext);
const { termsValidation } = useTerms();
const parameters = useParams();
+ // Filter native supported social connectors
+ const socialConnectors = useMemo(
+ () =>
+ (experienceSettings?.socialConnectors ?? []).filter(({ id }) => {
+ return !isNativeWebview() || getLogtoNativeSdk()?.supportedSocialConnectors.includes(id);
+ }),
+ [experienceSettings?.socialConnectors]
+ );
+
const { result: invokeSocialSignInResult, run: asyncInvokeSocialSignIn } =
useApi(invokeSocialSignIn);
@@ -151,6 +164,8 @@ const useSocial = () => {
return;
}
+ options?.onSocialSignInCallback?.();
+
// Invoke Native Social Sign In flow
if (isNativeWebview()) {
getLogtoNativeSdk()?.getPostMessage()({
@@ -163,7 +178,7 @@ const useSocial = () => {
// Invoke Web Social Sign In flow
window.location.assign(redirectTo);
- }, [invokeSocialSignInResult]);
+ }, [invokeSocialSignInResult, options]);
// SignInWithSocial Callback
useEffect(() => {
@@ -189,6 +204,10 @@ const useSocial = () => {
// Monitor Native Error Message
useEffect(() => {
+ if (!isNativeWebview()) {
+ return;
+ }
+
const nativeMessageHandler = (event: MessageEvent) => {
if (event.origin === window.location.origin) {
setToast(JSON.stringify(event.data));
@@ -203,6 +222,7 @@ const useSocial = () => {
}, [setToast]);
return {
+ socialConnectors,
invokeSocialSignIn: invokeSocialSignInHandler,
socialCallbackHandler,
};
diff --git a/packages/ui/src/pages/SignIn/index.module.scss b/packages/ui/src/pages/SignIn/index.module.scss
index f58a976f2..736f59b2a 100644
--- a/packages/ui/src/pages/SignIn/index.module.scss
+++ b/packages/ui/src/pages/SignIn/index.module.scss
@@ -9,6 +9,25 @@
margin-bottom: _.unit(12);
}
+ .terms {
+ margin-bottom: _.unit(4);
+ }
+
+ .divider {
+ margin-top: _.unit(4);
+ }
+
+ .primarySignIn {
+ margin-bottom: _.unit(5);
+ }
+
+ .primarySocial {
+ margin-bottom: _.unit(4);
+ }
+
+ .otherMethodsLink {
+ margin-top: _.unit(1);
+ }
.createAccount {
position: fixed;
diff --git a/packages/ui/src/pages/SignIn/index.test.tsx b/packages/ui/src/pages/SignIn/index.test.tsx
index 679f708e5..c4691b31e 100644
--- a/packages/ui/src/pages/SignIn/index.test.tsx
+++ b/packages/ui/src/pages/SignIn/index.test.tsx
@@ -1,11 +1,61 @@
-import { render } from '@testing-library/react';
import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
+import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
+import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SignIn from '@/pages/SignIn';
describe('', () => {
- test('renders without exploding', async () => {
- const { queryByText } = render();
+ test('renders with username as primary', async () => {
+ const { queryByText, container } = renderWithPageContext(
+
+
+
+
+
+ );
+ expect(container.querySelector('input[name="username"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
});
+
+ test('renders with email as primary', async () => {
+ const { queryByText, container } = renderWithPageContext(
+
+
+
+
+
+ );
+ expect(container.querySelector('input[name="email"]')).not.toBeNull();
+ expect(queryByText('action.continue')).not.toBeNull();
+ });
+
+ test('renders with sms as primary', async () => {
+ const { queryByText, container } = renderWithPageContext(
+
+
+
+
+
+ );
+ expect(container.querySelector('input[name="phone"]')).not.toBeNull();
+ expect(queryByText('action.continue')).not.toBeNull();
+ });
+
+ test('renders with social as primary', async () => {
+ const { container } = renderWithPageContext(
+
+
+
+
+
+ );
+
+ expect(container.querySelectorAll('button')).toHaveLength(3);
+ });
});
diff --git a/packages/ui/src/pages/SignIn/index.tsx b/packages/ui/src/pages/SignIn/index.tsx
index 6c1666e12..d3ef41a5c 100644
--- a/packages/ui/src/pages/SignIn/index.tsx
+++ b/packages/ui/src/pages/SignIn/index.tsx
@@ -4,10 +4,10 @@ import React, { useContext } from 'react';
import BrandingHeader from '@/components/BrandingHeader';
import TextLink from '@/components/TextLink';
-import UsernameSignin from '@/containers/UsernameSignin';
import { PageContext } from '@/hooks/use-page-context';
import * as styles from './index.module.scss';
+import { PrimarySection, SecondarySection } from './registry';
const SignIn = () => {
const { experienceSettings } = useContext(PageContext);
@@ -21,7 +21,11 @@ const SignIn = () => {
headline={style === BrandingStyle.Logo_Slogan ? slogan : undefined}
logo={logoUrl}
/>
-
+
+
{
+ switch (signInMethod) {
+ case 'email':
+ return ;
+ case 'sms':
+ return ;
+ case 'username':
+ return ;
+ case 'social':
+ return (
+ <>
+
+
+ >
+ );
+ default:
+ return null;
+ }
+};
+
+export const SecondarySection = ({
+ primarySignInMethod,
+ secondarySignInMethods,
+}: {
+ primarySignInMethod?: SignInMethod;
+ secondarySignInMethods?: SignInMethod[];
+}) => {
+ if (!primarySignInMethod || !secondarySignInMethods?.length) {
+ return null;
+ }
+
+ const localMethods = secondarySignInMethods.filter(
+ (method): method is LocalSignInMethod => method !== 'social'
+ );
+
+ if (primarySignInMethod === 'social' && localMethods.length > 0) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ {secondarySignInMethods.includes('social') && (
+ <>
+
+
+ >
+ )}
+ >
+ );
+};
diff --git a/packages/ui/src/scss/modal.module.scss b/packages/ui/src/scss/modal.module.scss
index 8ea3bcce8..0b6c0d95d 100644
--- a/packages/ui/src/scss/modal.module.scss
+++ b/packages/ui/src/scss/modal.module.scss
@@ -29,7 +29,7 @@
:global {
.ReactModal__Content[role='popup'] {
transform: translateY(100%);
- transition: transform 0.3 ease-in-out;
+ transition: transform 0.3s ease-in-out;
}
/* stylelint-disable selector-class-pattern */
diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts
index 8448a09b5..1f143a471 100644
--- a/packages/ui/src/types/index.ts
+++ b/packages/ui/src/types/index.ts
@@ -1,13 +1,16 @@
-import { Branding, LanguageInfo, TermsOfUse } from '@logto/schemas';
+import { Branding, LanguageInfo, TermsOfUse, ConnectorMetadata } from '@logto/schemas';
export type UserFlow = 'sign-in' | 'register';
export type SignInMethod = 'username' | 'email' | 'sms' | 'social';
export type LocalSignInMethod = 'username' | 'email' | 'sms';
+type ConnectorData = Pick;
+
export type SignInExperienceSettings = {
branding: Branding;
languageInfo: LanguageInfo;
termsOfUse: TermsOfUse;
primarySignInMethod: SignInMethod;
secondarySignInMethods: SignInMethod[];
+ socialConnectors: ConnectorData[];
};
diff --git a/packages/ui/src/utils/sign-in-experience.ts b/packages/ui/src/utils/sign-in-experience.ts
index 2e7f3f76a..2b386fd6a 100644
--- a/packages/ui/src/utils/sign-in-experience.ts
+++ b/packages/ui/src/utils/sign-in-experience.ts
@@ -5,6 +5,7 @@
import { SignInMethods } from '@logto/schemas';
+import { socialConnectors } from '@/__mocks__/logto';
import { getSignInExperience } from '@/apis/settings';
import { SignInMethod, SignInExperienceSettings } from '@/types';
@@ -36,6 +37,7 @@ const getSignInExperienceSettings = async (): Promise
termsOfUse,
primarySignInMethod: getPrimarySignInMethod(signInMethods),
secondarySignInMethods: getSecondarySignInMethods(signInMethods),
+ socialConnectors, // TODO: get values from api
};
};