From 29040b9c7cff56a1f18dea7669d82c2aa8143052 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 20 Nov 2023 10:14:33 +0800 Subject: [PATCH] feat(experience): add single sign on switch to the password sign-in form (#4898) * feat(experience): add single sign on switch to the password sign-in form add single sign on switch to the password sign-in form * feat(experience): add single sign-on message add single sign-on message --- .../src/hooks/use-check-single-sign-on.ts | 6 +- .../src/hooks/use-password-sign-in.ts | 3 + .../PasswordSignInForm/index.module.scss | 7 +- .../SignIn/PasswordSignInForm/index.test.tsx | 99 ++++++++++++++++++- .../pages/SignIn/PasswordSignInForm/index.tsx | 41 +++++--- .../use-single-sign-on-watch.ts | 92 +++++++++++++++++ .../src/locales/de/description.ts | 1 + .../src/locales/en/description.ts | 1 + .../src/locales/es/description.ts | 2 + .../src/locales/fr/description.ts | 1 + .../src/locales/it/description.ts | 1 + .../src/locales/ja/description.ts | 1 + .../src/locales/ko/description.ts | 1 + .../src/locales/pl-pl/description.ts | 1 + .../src/locales/pt-br/description.ts | 1 + .../src/locales/pt-pt/description.ts | 1 + .../src/locales/ru/description.ts | 1 + .../src/locales/tr-tr/description.ts | 1 + .../src/locales/zh-cn/description.ts | 1 + .../src/locales/zh-hk/description.ts | 1 + .../src/locales/zh-tw/description.ts | 1 + 21 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 packages/experience/src/pages/SignIn/PasswordSignInForm/use-single-sign-on-watch.ts diff --git a/packages/experience/src/hooks/use-check-single-sign-on.ts b/packages/experience/src/hooks/use-check-single-sign-on.ts index df907f927..0ef248cf3 100644 --- a/packages/experience/src/hooks/use-check-single-sign-on.ts +++ b/packages/experience/src/hooks/use-check-single-sign-on.ts @@ -47,11 +47,7 @@ const useCheckSingleSignOn = () => { } const connectors = result - ?.map((connectorId) => - availableSsoConnectorsMap.has(connectorId) - ? availableSsoConnectorsMap.get(connectorId) - : undefined - ) + ?.map((connectorId) => availableSsoConnectorsMap.get(connectorId)) // eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific .filter((connector): connector is SsoConnectorMetadata => Boolean(connector)); diff --git a/packages/experience/src/hooks/use-password-sign-in.ts b/packages/experience/src/hooks/use-password-sign-in.ts index c05d196d9..d4e42c23c 100644 --- a/packages/experience/src/hooks/use-password-sign-in.ts +++ b/packages/experience/src/hooks/use-password-sign-in.ts @@ -24,6 +24,9 @@ const usePasswordSignIn = () => { 'session.invalid_credentials': (error) => { setErrorMessage(error.message); }, + 'session.sso_enabled': (_error) => { + // Hide the toast and do nothing + }, ...preSignInErrorHandler, }), [preSignInErrorHandler] diff --git a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.module.scss b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.module.scss index e66a1631b..322fb240c 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.module.scss +++ b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.module.scss @@ -10,7 +10,8 @@ .inputField, .link, .terms, - .formErrors { + .formErrors, + .message { margin-bottom: _.unit(4); } @@ -20,6 +21,10 @@ width: auto; } + .message { + @include _.text-hint; + } + .formErrors { margin-left: _.unit(0.5); margin-top: _.unit(-3); diff --git a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx index 3fb6dccd1..503cecfcd 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx +++ b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx @@ -3,10 +3,12 @@ import { assert } from '@silverhand/essentials'; import { fireEvent, waitFor } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; +import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; -import { mockSignInExperienceSettings } from '@/__mocks__/logto'; +import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { signInWithPasswordIdentifier } from '@/apis/interaction'; +import { singleSignOnPath } from '@/constants/env'; import type { SignInExperienceResponse } from '@/types'; import { getDefaultCountryCallingCode } from '@/utils/country-code'; @@ -17,12 +19,24 @@ jest.mock('react-device-detect', () => ({ isMobile: true, })); +const mockedNavigate = jest.fn(); +const getSingleSignOnConnectorsMock = jest.fn(); + jest.mock('i18next', () => ({ ...jest.requireActual('i18next'), language: 'en', t: (key: string) => key, })); +jest.mock('@/apis/single-sign-on', () => ({ + getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockedNavigate, +})); + describe('UsernamePasswordSignInForm', () => { afterEach(() => { jest.clearAllMocks(); @@ -34,7 +48,9 @@ describe('UsernamePasswordSignInForm', () => { ) => renderWithPageContext( - + + + ); @@ -165,4 +181,83 @@ describe('UsernamePasswordSignInForm', () => { }); }); }); + + test('should switch to single sign on form when single sign on is enabled for a give email', async () => { + const { getByText, queryByText, container } = renderPasswordSignInForm( + [SignInIdentifier.Username, SignInIdentifier.Email], + { + ssoConnectors: mockSsoConnectors, + } + ); + + const passwordFormAssertion = () => { + expect(container.querySelector('input[name="password"]')).not.toBeNull(); + expect(queryByText('action.sign_in')).not.toBeNull(); + }; + + const singleSignOnFormAssertion = () => { + expect(container.querySelector('input[name="password"]')).toBeNull(); + expect(queryByText('action.sign_in')).toBeNull(); + expect(queryByText('action.single_sign_on')).not.toBeNull(); + }; + + const identifierInput = container.querySelector('input[name="identifier"]'); + assert(identifierInput, new Error('identifier input should exist')); + + // Default + passwordFormAssertion(); + + // Username + act(() => { + fireEvent.change(identifierInput, { target: { value: 'foo' } }); + }); + passwordFormAssertion(); + + // Invalid email + act(() => { + fireEvent.change(identifierInput, { target: { value: 'foo@l' } }); + }); + passwordFormAssertion(); + expect(getSingleSignOnConnectorsMock).not.toBeCalled(); + + // Valid email with empty response + const email = 'foo@logto.io'; + getSingleSignOnConnectorsMock.mockResolvedValueOnce([]); + act(() => { + fireEvent.change(identifierInput, { target: { value: email } }); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).toBeCalledWith(email); + }); + + passwordFormAssertion(); + + // Valid email with response + const email2 = 'foo@bar.io'; + getSingleSignOnConnectorsMock.mockClear(); + getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id)); + + act(() => { + fireEvent.change(identifierInput, { target: { value: email2 } }); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).toBeCalledWith(email2); + }); + + await waitFor(() => { + singleSignOnFormAssertion(); + }); + + const submitButton = getByText('action.single_sign_on'); + + act(() => { + fireEvent.submit(submitButton); + }); + + await waitFor(() => { + expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`); + }); + }); }); diff --git a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx index 3c3dbc6a7..b09639de7 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx +++ b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx @@ -14,6 +14,7 @@ import { useForgotPasswordSettings } from '@/hooks/use-sie'; import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form'; import * as styles from './index.module.scss'; +import useSingleSignOnWatch from './use-single-sign-on-watch'; type Props = { className?: string; @@ -22,7 +23,7 @@ type Props = { signInMethods: SignInIdentifier[]; }; -type FormState = { +export type FormState = { identifier: IdentifierInputValue; password: string; }; @@ -47,11 +48,18 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => { }, }); + const { showSingleSignOn, navigateToSingleSignOn } = useSingleSignOnWatch(control); + const onSubmitHandler = useCallback( async (event?: React.FormEvent) => { clearErrorMessage(); void handleSubmit(async ({ identifier: { type, value }, password }) => { + if (showSingleSignOn) { + navigateToSingleSignOn(); + return; + } + if (!type) { return; } @@ -62,7 +70,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => { }); })(event); }, - [clearErrorMessage, handleSubmit, onSubmit] + [clearErrorMessage, handleSubmit, navigateToSingleSignOn, onSubmit, showSingleSignOn] ); useEffect(() => { @@ -98,19 +106,24 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => { /> )} /> + {showSingleSignOn && ( +
{t('description.single_sign_on_enabled')}
+ )} - + {!showSingleSignOn && ( + + )} {errorMessage && {errorMessage}} - {isForgotPasswordEnabled && ( + {isForgotPasswordEnabled && !showSingleSignOn && ( { /> )} -