From d6de6257544ba8426737d7c4842c7a32339db714 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Thu, 30 Nov 2023 18:06:07 +0800 Subject: [PATCH] feat(experience): enable SSO auto watch to the sign-in and register form (#5014) * feat(experience): enable SSO auto watch to the sign-in and register form enable SSO auto watch to the sign-in and register form * fix(experience): remove unused style remove unused style --- .../SingleSignOnFormModeContext.tsx | 18 ++++++ .../index.tsx | 23 +++++++ .../InputFields/SmartInputField/index.tsx | 2 +- packages/experience/src/hooks/use-sie.ts | 4 +- .../use-single-sign-on-watch.ts | 49 ++++++++------- .../IdentifierRegisterForm/index.module.scss | 11 +++- .../IdentifierRegisterForm/index.test.tsx | 46 ++++++++++---- .../Register/IdentifierRegisterForm/index.tsx | 44 +++++++++++-- .../src/pages/Register/index.test.tsx | 2 +- .../experience/src/pages/Register/index.tsx | 62 +++++++++++++------ .../IdentifierSignInForm/index.module.scss | 7 ++- .../IdentifierSignInForm/index.test.tsx | 49 +++++++++++---- .../SignIn/IdentifierSignInForm/index.tsx | 28 ++++++++- .../SignIn/PasswordSignInForm/index.test.tsx | 5 +- .../pages/SignIn/PasswordSignInForm/index.tsx | 20 +++--- .../experience/src/pages/SignIn/index.tsx | 49 +++++++++++---- 16 files changed, 322 insertions(+), 97 deletions(-) create mode 100644 packages/experience/src/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext.tsx create mode 100644 packages/experience/src/Providers/SingleSignOnFormModeContextProvider/index.tsx rename packages/experience/src/{pages/SignIn/PasswordSignInForm => hooks}/use-single-sign-on-watch.ts (68%) diff --git a/packages/experience/src/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext.tsx b/packages/experience/src/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext.tsx new file mode 100644 index 000000000..1c819352b --- /dev/null +++ b/packages/experience/src/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext.tsx @@ -0,0 +1,18 @@ +import { noop } from '@silverhand/essentials'; +import { createContext } from 'react'; + +export type SingleSignOnFormModeContextType = { + showSingleSignOnForm: boolean; + setShowSingleSignOnForm: React.Dispatch>; +}; + +/** + * This context is used to share the single sign on identifier status cross the page and form components. + * If the user has entered an identifier that is associated with a single sign on method, we will show the single sign on form. + */ +const SingleSignOnFormModeContext = createContext({ + showSingleSignOnForm: false, + setShowSingleSignOnForm: noop, +}); + +export default SingleSignOnFormModeContext; diff --git a/packages/experience/src/Providers/SingleSignOnFormModeContextProvider/index.tsx b/packages/experience/src/Providers/SingleSignOnFormModeContextProvider/index.tsx new file mode 100644 index 000000000..f6db6e89b --- /dev/null +++ b/packages/experience/src/Providers/SingleSignOnFormModeContextProvider/index.tsx @@ -0,0 +1,23 @@ +import { useState, useMemo, type ReactNode } from 'react'; + +import SingleSignOnFormModeContext from './SingleSignOnFormModeContext'; + +const SingleSignOnFormModeContextProvider = ({ children }: { children: ReactNode }) => { + const [showSingleSignOnForm, setShowSingleSignOnForm] = useState(false); + + const contextValue = useMemo( + () => ({ + showSingleSignOnForm, + setShowSingleSignOnForm, + }), + [showSingleSignOnForm] + ); + + return ( + + {children} + + ); +}; + +export default SingleSignOnFormModeContextProvider; diff --git a/packages/experience/src/components/InputFields/SmartInputField/index.tsx b/packages/experience/src/components/InputFields/SmartInputField/index.tsx index 2d0a295d0..fb9b74ae5 100644 --- a/packages/experience/src/components/InputFields/SmartInputField/index.tsx +++ b/packages/experience/src/components/InputFields/SmartInputField/index.tsx @@ -66,8 +66,8 @@ const SmartInputField = ( return ( z-index to override country selector diff --git a/packages/experience/src/hooks/use-sie.ts b/packages/experience/src/hooks/use-sie.ts index e432e7517..c4e286fef 100644 --- a/packages/experience/src/hooks/use-sie.ts +++ b/packages/experience/src/hooks/use-sie.ts @@ -5,6 +5,7 @@ import { useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import PageContext from '@/Providers/PageContextProvider/PageContext'; +import { isDevFeaturesEnabled } from '@/constants/env'; import { type VerificationCodeIdentifier } from '@/types'; export const useSieMethods = () => { @@ -24,7 +25,8 @@ export const useSieMethods = () => { signInMode: experienceSettings?.signInMode, forgotPassword: experienceSettings?.forgotPassword, customContent: experienceSettings?.customContent, - singleSignOnEnabled: experienceSettings?.singleSignOnEnabled, + // TODO: remove the dev feature check once SSO is ready + singleSignOnEnabled: isDevFeaturesEnabled && experienceSettings?.singleSignOnEnabled, }; }; diff --git a/packages/experience/src/pages/SignIn/PasswordSignInForm/use-single-sign-on-watch.ts b/packages/experience/src/hooks/use-single-sign-on-watch.ts similarity index 68% rename from packages/experience/src/pages/SignIn/PasswordSignInForm/use-single-sign-on-watch.ts rename to packages/experience/src/hooks/use-single-sign-on-watch.ts index 7b75d5bf8..29eeb28f1 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/use-single-sign-on-watch.ts +++ b/packages/experience/src/hooks/use-single-sign-on-watch.ts @@ -1,32 +1,31 @@ import { SignInIdentifier, type SsoConnectorMetadata } from '@logto/schemas'; -import { useEffect, useState, useCallback, useContext } from 'react'; -import { type Control, useWatch } from 'react-hook-form'; +import { useEffect, useCallback, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext'; +import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext'; import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; +import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import { singleSignOnPath } from '@/constants/env'; import useApi from '@/hooks/use-api'; import useSingleSignOn from '@/hooks/use-single-sign-on'; import { validateEmail } from '@/utils/form'; -import type { FormState } from './index'; +import { useSieMethods } from './use-sie'; -const useSingleSignOnWatch = (control: Control) => { +const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => { const navigate = useNavigate(); + + const { singleSignOnEnabled } = useSieMethods(); + const { setEmail, setSsoConnectors, ssoConnectors, availableSsoConnectorsMap } = useContext(SingleSignOnContext); - const [showSingleSignOn, setShowSingleSignOn] = useState(false); + + const { showSingleSignOnForm, setShowSingleSignOnForm } = useContext(SingleSignOnFormModeContext); + const request = useApi(getSingleSignOnConnectors); const singleSignOn = useSingleSignOn(); - const isSsoEnabled = availableSsoConnectorsMap.size > 0; - - const identifierInput = useWatch({ - control, - name: 'identifier', - }); - /** * Silently check if the email is registered with any SSO connectors */ @@ -56,15 +55,15 @@ const useSingleSignOnWatch = (control: Control) => { // Reset the ssoContext useEffect(() => { - if (!showSingleSignOn) { + if (!showSingleSignOnForm) { setSsoConnectors([]); // eslint-disable-next-line unicorn/no-useless-undefined setEmail(undefined); } - }, [setEmail, setSsoConnectors, showSingleSignOn]); + }, [setEmail, setSsoConnectors, showSingleSignOnForm]); const navigateToSingleSignOn = useCallback(async () => { - if (!showSingleSignOn) { + if (!showSingleSignOnForm) { return; } @@ -75,38 +74,44 @@ const useSingleSignOnWatch = (control: Control) => { } navigate(`/${singleSignOnPath}/connectors`); - }, [navigate, showSingleSignOn, singleSignOn, ssoConnectors]); + }, [navigate, showSingleSignOnForm, singleSignOn, ssoConnectors]); useEffect(() => { - if (!isSsoEnabled) { + if (!singleSignOnEnabled) { + return; + } + + // Input is undefined if no user interaction has happened + if (!identifierInput) { + setShowSingleSignOnForm(false); return; } const { type, value } = identifierInput; if (type !== SignInIdentifier.Email) { - setShowSingleSignOn(false); + setShowSingleSignOnForm(false); return; } // Will throw an error if the value is not a valid email if (validateEmail(value)) { - setShowSingleSignOn(false); + setShowSingleSignOnForm(false); return; } // Add a debouncing delay to avoid unnecessary API calls const handler = setTimeout(async () => { - setShowSingleSignOn(await fetchSsoConnectors(value)); + setShowSingleSignOnForm(await fetchSsoConnectors(value)); }, 300); return () => { clearTimeout(handler); }; - }, [fetchSsoConnectors, identifierInput, isSsoEnabled]); + }, [fetchSsoConnectors, identifierInput, setShowSingleSignOnForm, singleSignOnEnabled]); return { - showSingleSignOn, + showSingleSignOnForm, navigateToSingleSignOn, }; }; diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.module.scss b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.module.scss index 91051d3a3..8fa4bbd9a 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.module.scss +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.module.scss @@ -9,12 +9,21 @@ .inputField, .terms, - .formErrors { + .formErrors, + .message { margin-bottom: _.unit(4); } + .message { + @include _.text-hint; + } + .formErrors { margin-left: _.unit(0.5); margin-top: _.unit(-3); } + + .hidden { + display: none; + } } diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx index 2efbe9c29..aaaa030e1 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, act, waitFor } from '@testing-library/react'; import ConfirmModalProvider from '@/Providers/ConfirmModalProvider'; import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider'; +import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; @@ -38,7 +39,7 @@ jest.mock('@/apis/interaction', () => ({ })); jest.mock('@/apis/single-sign-on', () => ({ - getSingleSignOnConnectors: () => getSingleSignOnConnectorsMock(), + getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email), })); const renderForm = ( @@ -54,7 +55,9 @@ const renderForm = ( > - + + + @@ -305,7 +308,7 @@ describe('', () => { const email = 'foo@email.com'; it('should not call check single sign-on connector when no single sign-on connector is enabled', async () => { - const { getByText, container } = renderForm([SignInIdentifier.Email]); + const { getByText, container, queryByText } = renderForm([SignInIdentifier.Email]); const submitButton = getByText('action.create_account'); const emailInput = container.querySelector('input[name="identifier"]'); const termsButton = getByText('description.agree_with_terms'); @@ -317,6 +320,8 @@ describe('', () => { fireEvent.click(termsButton); }); + expect(queryByText('action.single_sign_on')).toBeNull(); + act(() => { fireEvent.submit(submitButton); }); @@ -332,7 +337,10 @@ describe('', () => { it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => { getSingleSignOnConnectorsMock.mockRejectedValueOnce([]); - const { getByText, container } = renderForm([SignInIdentifier.Email], mockSsoConnectors); + const { getByText, container, queryByText } = renderForm( + [SignInIdentifier.Email], + mockSsoConnectors + ); const submitButton = getByText('action.create_account'); const emailInput = container.querySelector('input[name="identifier"]'); const termsButton = getByText('description.agree_with_terms'); @@ -344,39 +352,55 @@ describe('', () => { fireEvent.click(termsButton); }); + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).toBeCalledWith(email); + }); + act(() => { fireEvent.submit(submitButton); }); + // Should not switch to the single sign-on mode + expect(queryByText('action.single_sign_on')).toBeNull(); + await waitFor(() => { - expect(getSingleSignOnConnectorsMock).toBeCalled(); expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, { email, }); }); }); - it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => { + it('should call check single sign-on connector when the identifier is email, and goes to the SSO flow', async () => { getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id)); - const { getByText, container } = renderForm([SignInIdentifier.Email], mockSsoConnectors); - const submitButton = getByText('action.create_account'); + const { getByText, container, queryByText } = renderForm( + [SignInIdentifier.Email], + mockSsoConnectors + ); const emailInput = container.querySelector('input[name="identifier"]'); - const termsButton = getByText('description.agree_with_terms'); assert(emailInput, new Error('username input not found')); act(() => { fireEvent.change(emailInput, { target: { value: email } }); - fireEvent.click(termsButton); + }); + + await waitFor(() => { + expect(getSingleSignOnConnectorsMock).toBeCalledWith(email); + }); + + await waitFor(() => { + // Should switch to the single sign-on mode + expect(queryByText('action.single_sign_on')).not.toBeNull(); + expect(queryByText('action.create_account')).toBeNull(); }); act(() => { + const submitButton = getByText('action.single_sign_on'); fireEvent.submit(submitButton); }); await waitFor(() => { - expect(getSingleSignOnConnectorsMock).toBeCalled(); expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`); }); }); diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx index fb1d3bedb..eaac9c1f5 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx @@ -4,11 +4,13 @@ import { useCallback, useEffect } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import LockIcon from '@/assets/icons/lock.svg'; import Button from '@/components/Button'; import ErrorMessage from '@/components/ErrorMessage'; import { SmartInputField } from '@/components/InputFields'; import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import TermsAndPrivacy from '@/containers/TermsAndPrivacy'; +import useSingleSignOnWatch from '@/hooks/use-single-sign-on-watch'; import useTerms from '@/hooks/use-terms'; import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form'; @@ -33,6 +35,7 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(); const { + watch, handleSubmit, formState: { errors, isValid }, control, @@ -40,6 +43,11 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) reValidateMode: 'onBlur', }); + // Watch identifier field and check single sign on method availability + const { showSingleSignOnForm, navigateToSingleSignOn } = useSingleSignOnWatch( + watch('identifier') + ); + useEffect(() => { if (!isValid) { clearErrorMessage(); @@ -55,6 +63,11 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) return; } + if (showSingleSignOnForm) { + await navigateToSingleSignOn(); + return; + } + if (!(await termsValidation())) { return; } @@ -62,7 +75,14 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) await onSubmit(type, value); })(event); }, - [clearErrorMessage, handleSubmit, onSubmit, termsValidation] + [ + clearErrorMessage, + handleSubmit, + navigateToSingleSignOn, + onSubmit, + showSingleSignOnForm, + termsValidation, + ] ); return ( @@ -89,7 +109,6 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) }} render={({ field }) => ( {errorMessage}} - + {showSingleSignOnForm && ( +
{t('description.single_sign_on_enabled')}
+ )} -