mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
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
This commit is contained in:
parent
a763d9e5a5
commit
d6de625754
16 changed files with 322 additions and 97 deletions
|
@ -0,0 +1,18 @@
|
|||
import { noop } from '@silverhand/essentials';
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type SingleSignOnFormModeContextType = {
|
||||
showSingleSignOnForm: boolean;
|
||||
setShowSingleSignOnForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<SingleSignOnFormModeContextType>({
|
||||
showSingleSignOnForm: false,
|
||||
setShowSingleSignOnForm: noop,
|
||||
});
|
||||
|
||||
export default SingleSignOnFormModeContext;
|
|
@ -0,0 +1,23 @@
|
|||
import { useState, useMemo, type ReactNode } from 'react';
|
||||
|
||||
import SingleSignOnFormModeContext from './SingleSignOnFormModeContext';
|
||||
|
||||
const SingleSignOnFormModeContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [showSingleSignOnForm, setShowSingleSignOnForm] = useState<boolean>(false);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
showSingleSignOnForm,
|
||||
setShowSingleSignOnForm,
|
||||
}),
|
||||
[showSingleSignOnForm]
|
||||
);
|
||||
|
||||
return (
|
||||
<SingleSignOnFormModeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SingleSignOnFormModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleSignOnFormModeContextProvider;
|
|
@ -66,8 +66,8 @@ const SmartInputField = (
|
|||
|
||||
return (
|
||||
<AnimatedInputField
|
||||
{...rest}
|
||||
{...getInputHtmlProps(enabledTypes, identifierType)}
|
||||
{...rest}
|
||||
ref={innerRef}
|
||||
isSuffixFocusVisible={Boolean(inputValue)}
|
||||
style={{ zIndex: 1, paddingLeft }} // Give <input /> z-index to override country selector
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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<FormState>) => {
|
||||
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<FormState>) => {
|
|||
|
||||
// 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<FormState>) => {
|
|||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = (
|
|||
>
|
||||
<ConfirmModalProvider>
|
||||
<SingleSignOnContextProvider>
|
||||
<IdentifierRegisterForm signUpMethods={signUpMethods} />
|
||||
<SingleSignOnFormModeContextProvider>
|
||||
<IdentifierRegisterForm signUpMethods={signUpMethods} />
|
||||
</SingleSignOnFormModeContextProvider>
|
||||
</SingleSignOnContextProvider>
|
||||
</ConfirmModalProvider>
|
||||
</SettingsProvider>
|
||||
|
@ -305,7 +308,7 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
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('<IdentifierRegisterForm />', () => {
|
|||
fireEvent.click(termsButton);
|
||||
});
|
||||
|
||||
expect(queryByText('action.single_sign_on')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
@ -332,7 +337,10 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
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('<IdentifierRegisterForm />', () => {
|
|||
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`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }) => (
|
||||
<SmartInputField
|
||||
autoComplete="off"
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
{...field}
|
||||
|
@ -102,9 +121,26 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
|
|||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
<TermsAndPrivacy className={styles.terms} />
|
||||
{showSingleSignOnForm && (
|
||||
<div className={styles.message}>{t('description.single_sign_on_enabled')}</div>
|
||||
)}
|
||||
|
||||
<Button name="submit" title="action.create_account" htmlType="submit" />
|
||||
{/**
|
||||
* Have to use css to hide the terms element.
|
||||
* Remove element from dom will trigger a form re-render.
|
||||
* Form rerender will trigger autofill.
|
||||
* If the autofill value is SSO enabled, it will always show SSO form.
|
||||
*/}
|
||||
<TermsAndPrivacy
|
||||
className={classNames(styles.terms, showSingleSignOnForm && styles.hidden)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
name="submit"
|
||||
title={showSingleSignOnForm ? 'action.single_sign_on' : 'action.create_account'}
|
||||
icon={showSingleSignOnForm ? <LockIcon /> : undefined}
|
||||
htmlType="submit"
|
||||
/>
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
|
|
|
@ -83,7 +83,7 @@ describe('<Register />', () => {
|
|||
expect(queryByText('action.single_sign_on')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('should render single sign on link with single sign on enabled but empty list', () => {
|
||||
test('should render single sign on link with single sign on enabled but empty list', () => {
|
||||
const { queryByText } = renderRegisterPage({
|
||||
ssoConnectors: [],
|
||||
singleSignOnEnabled: true,
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { SignInMode } from '@logto/schemas';
|
||||
import { useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import LandingPageLayout from '@/Layout/LandingPageLayout';
|
||||
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
|
||||
import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext';
|
||||
import Divider from '@/components/Divider';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { isDevFeaturesEnabled } from '@/constants/env';
|
||||
import SocialSignInList from '@/containers/SocialSignInList';
|
||||
import TermsAndPrivacy from '@/containers/TermsAndPrivacy';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
|
@ -15,33 +17,24 @@ import ErrorPage from '../ErrorPage';
|
|||
import IdentifierRegisterForm from './IdentifierRegisterForm';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const Register = () => {
|
||||
const RegisterFooter = () => {
|
||||
const { signUpMethods, socialConnectors, signInMode, signInMethods, singleSignOnEnabled } =
|
||||
useSieMethods();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!signInMode) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
const { showSingleSignOnForm } = useContext(SingleSignOnFormModeContext);
|
||||
|
||||
if (signInMode === SignInMode.SignIn) {
|
||||
return <Navigate to="/sign-in" />;
|
||||
/* Hide footers when showing Single Sign On form */
|
||||
if (showSingleSignOnForm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LandingPageLayout title="description.create_your_account">
|
||||
{signUpMethods.length > 0 && (
|
||||
<IdentifierRegisterForm signUpMethods={signUpMethods} className={styles.main} />
|
||||
)}
|
||||
{signUpMethods.length === 0 && socialConnectors.length > 0 && (
|
||||
<>
|
||||
<TermsAndPrivacy className={styles.terms} />
|
||||
<SocialSignInList className={styles.main} socialConnectors={socialConnectors} />
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
{
|
||||
// Single Sign On footer TODO: remove the dev feature check once SSO is ready
|
||||
isDevFeaturesEnabled && singleSignOnEnabled && (
|
||||
// Single Sign On footer
|
||||
singleSignOnEnabled && (
|
||||
<div className={styles.singleSignOn}>
|
||||
{t('description.use')}{' '}
|
||||
<TextLink to="/single-sign-on/email" text="action.single_sign_on" />
|
||||
|
@ -65,6 +58,37 @@ const Register = () => {
|
|||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Register = () => {
|
||||
const { signUpMethods, socialConnectors, signInMode } = useSieMethods();
|
||||
|
||||
if (!signInMode) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (signInMode === SignInMode.SignIn) {
|
||||
return <Navigate to="/sign-in" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LandingPageLayout title="description.create_your_account">
|
||||
<SingleSignOnFormModeContextProvider>
|
||||
{signUpMethods.length > 0 && (
|
||||
<IdentifierRegisterForm signUpMethods={signUpMethods} className={styles.main} />
|
||||
)}
|
||||
{signUpMethods.length === 0 && socialConnectors.length > 0 && (
|
||||
<>
|
||||
<TermsAndPrivacy className={styles.terms} />
|
||||
<SocialSignInList className={styles.main} socialConnectors={socialConnectors} />
|
||||
</>
|
||||
)}
|
||||
<RegisterFooter />
|
||||
</SingleSignOnFormModeContextProvider>
|
||||
|
||||
{/* Hide footer elements when showing Single Sign On form */}
|
||||
</LandingPageLayout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,10 +8,15 @@
|
|||
}
|
||||
|
||||
.inputField,
|
||||
.formErrors {
|
||||
.formErrors,
|
||||
.message {
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.message {
|
||||
@include _.text-hint;
|
||||
}
|
||||
|
||||
.formErrors {
|
||||
margin-left: _.unit(0.5);
|
||||
margin-top: _.unit(-3);
|
||||
|
|
|
@ -4,6 +4,7 @@ import { assert } from '@silverhand/essentials';
|
|||
import { fireEvent, act, waitFor } from '@testing-library/react';
|
||||
|
||||
import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider';
|
||||
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import {
|
||||
|
@ -37,7 +38,7 @@ jest.mock('react-router-dom', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('@/apis/single-sign-on', () => ({
|
||||
getSingleSignOnConnectors: () => getSingleSignOnConnectorsMock(),
|
||||
getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
|
||||
}));
|
||||
|
||||
const username = 'foo';
|
||||
|
@ -53,7 +54,9 @@ const renderForm = (signInMethods: SignIn['methods'], ssoConnectors: SsoConnecto
|
|||
}}
|
||||
>
|
||||
<SingleSignOnContextProvider>
|
||||
<IdentifierSignInForm signInMethods={signInMethods} />
|
||||
<SingleSignOnFormModeContextProvider>
|
||||
<IdentifierSignInForm signInMethods={signInMethods} />
|
||||
</SingleSignOnFormModeContextProvider>
|
||||
</SingleSignOnContextProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
@ -178,7 +181,7 @@ describe('IdentifierSignInForm', () => {
|
|||
|
||||
describe('email single sign-on tests', () => {
|
||||
it('should not call check single sign-on connector when the identifier is not email', async () => {
|
||||
const { getByText, container } = renderForm(
|
||||
const { getByText, container, queryByText } = renderForm(
|
||||
mockSignInMethodSettingsTestCases[0]!,
|
||||
mockSsoConnectors
|
||||
);
|
||||
|
@ -192,6 +195,9 @@ describe('IdentifierSignInForm', () => {
|
|||
});
|
||||
}
|
||||
|
||||
expect(queryByText('action.sign_in')).not.toBeNull();
|
||||
expect(queryByText('action.single_sign_on')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
@ -206,7 +212,9 @@ describe('IdentifierSignInForm', () => {
|
|||
});
|
||||
|
||||
it('should not call check single sign-on connector when no single sign-on connector is enabled', async () => {
|
||||
const { getByText, container } = renderForm(mockSignInMethodSettingsTestCases[0]!);
|
||||
const { getByText, container, queryByText } = renderForm(
|
||||
mockSignInMethodSettingsTestCases[0]!
|
||||
);
|
||||
|
||||
const inputField = container.querySelector('input[name="identifier"]');
|
||||
const submitButton = getByText('action.sign_in');
|
||||
|
@ -217,6 +225,9 @@ describe('IdentifierSignInForm', () => {
|
|||
});
|
||||
}
|
||||
|
||||
expect(queryByText('action.sign_in')).not.toBeNull();
|
||||
expect(queryByText('action.single_sign_on')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
@ -231,15 +242,14 @@ describe('IdentifierSignInForm', () => {
|
|||
});
|
||||
|
||||
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([]);
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce([]);
|
||||
|
||||
const { getByText, container } = renderForm(
|
||||
const { getByText, container, queryByText } = renderForm(
|
||||
mockSignInMethodSettingsTestCases[0]!,
|
||||
mockSsoConnectors
|
||||
);
|
||||
|
||||
const inputField = container.querySelector('input[name="identifier"]');
|
||||
const submitButton = getByText('action.sign_in');
|
||||
|
||||
if (inputField) {
|
||||
act(() => {
|
||||
|
@ -247,12 +257,20 @@ describe('IdentifierSignInForm', () => {
|
|||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).toBeCalledWith(email);
|
||||
});
|
||||
|
||||
// Should not switch to the single sign-on mode
|
||||
expect(queryByText('action.single_sign_on')).toBeNull();
|
||||
|
||||
const submitButton = getByText('action.sign_in');
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).toBeCalled();
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
{ pathname: '/sign-in/password' },
|
||||
{ state: { identifier: SignInIdentifier.Email, value: email } }
|
||||
|
@ -263,13 +281,12 @@ describe('IdentifierSignInForm', () => {
|
|||
it('should call check single sign-on connector when the identifier is email, and process to single sign-on if a sso connector is matched', async () => {
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id));
|
||||
|
||||
const { getByText, container } = renderForm(
|
||||
const { getByText, container, queryByText } = renderForm(
|
||||
mockSignInMethodSettingsTestCases[0]!,
|
||||
mockSsoConnectors
|
||||
);
|
||||
|
||||
const inputField = container.querySelector('input[name="identifier"]');
|
||||
const submitButton = getByText('action.sign_in');
|
||||
|
||||
if (inputField) {
|
||||
act(() => {
|
||||
|
@ -277,12 +294,22 @@ describe('IdentifierSignInForm', () => {
|
|||
});
|
||||
}
|
||||
|
||||
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.sign_in')).toBeNull();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const submitButton = getByText('action.single_sign_on');
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).toBeCalled();
|
||||
expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,11 +2,14 @@ import type { SignIn } from '@logto/schemas';
|
|||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useMemo } 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 useSingleSignOnWatch from '@/hooks/use-single-sign-on-watch';
|
||||
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -24,6 +27,7 @@ type FormState = {
|
|||
};
|
||||
|
||||
const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(signInMethods);
|
||||
|
||||
const enabledSignInMethods = useMemo(
|
||||
|
@ -32,6 +36,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
|
|||
);
|
||||
|
||||
const {
|
||||
watch,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
|
@ -39,6 +44,11 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
|
|||
reValidateMode: 'onBlur',
|
||||
});
|
||||
|
||||
// Watch identifier field and check single sign on method availability
|
||||
const { showSingleSignOnForm, navigateToSingleSignOn } = useSingleSignOnWatch(
|
||||
watch('identifier')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValid) {
|
||||
clearErrorMessage();
|
||||
|
@ -54,10 +64,15 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
|
|||
return;
|
||||
}
|
||||
|
||||
if (showSingleSignOnForm) {
|
||||
await navigateToSingleSignOn();
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(type, value);
|
||||
})(event);
|
||||
},
|
||||
[clearErrorMessage, handleSubmit, onSubmit]
|
||||
[clearErrorMessage, handleSubmit, navigateToSingleSignOn, onSubmit, showSingleSignOnForm]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -92,7 +107,16 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
|
|||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
<Button title="action.sign_in" htmlType="submit" />
|
||||
{showSingleSignOnForm && (
|
||||
<div className={styles.message}>{t('description.single_sign_on_enabled')}</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
name="submit"
|
||||
title={showSingleSignOnForm ? 'action.single_sign_on' : 'action.sign_in'}
|
||||
icon={showSingleSignOnForm ? <LockIcon /> : undefined}
|
||||
htmlType="submit"
|
||||
/>
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
|
|
|
@ -4,6 +4,7 @@ import { fireEvent, waitFor } from '@testing-library/react';
|
|||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
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';
|
||||
|
@ -51,7 +52,9 @@ describe('UsernamePasswordSignInForm', () => {
|
|||
renderWithPageContext(
|
||||
<SettingsProvider settings={{ ...mockSignInExperienceSettings, ...settings }}>
|
||||
<SingleSignOnContextProvider>
|
||||
<PasswordSignInForm signInMethods={signInMethods} />
|
||||
<SingleSignOnFormModeContextProvider>
|
||||
<PasswordSignInForm signInMethods={signInMethods} />
|
||||
</SingleSignOnFormModeContextProvider>
|
||||
</SingleSignOnContextProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
|
|
@ -12,10 +12,10 @@ import type { IdentifierInputValue } from '@/components/InputFields/SmartInputFi
|
|||
import ForgotPasswordLink from '@/containers/ForgotPasswordLink';
|
||||
import usePasswordSignIn from '@/hooks/use-password-sign-in';
|
||||
import { useForgotPasswordSettings } from '@/hooks/use-sie';
|
||||
import useSingleSignOnWatch from '@/hooks/use-single-sign-on-watch';
|
||||
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;
|
||||
|
@ -49,7 +49,9 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
},
|
||||
});
|
||||
|
||||
const { showSingleSignOn, navigateToSingleSignOn } = useSingleSignOnWatch(control);
|
||||
const { showSingleSignOnForm, navigateToSingleSignOn } = useSingleSignOnWatch(
|
||||
watch('identifier')
|
||||
);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
|
@ -60,7 +62,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (showSingleSignOn) {
|
||||
if (showSingleSignOnForm) {
|
||||
await navigateToSingleSignOn();
|
||||
return;
|
||||
}
|
||||
|
@ -71,7 +73,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
});
|
||||
})(event);
|
||||
},
|
||||
[clearErrorMessage, handleSubmit, navigateToSingleSignOn, onSubmit, showSingleSignOn]
|
||||
[clearErrorMessage, handleSubmit, navigateToSingleSignOn, onSubmit, showSingleSignOnForm]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -107,11 +109,11 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
{showSingleSignOn && (
|
||||
{showSingleSignOnForm && (
|
||||
<div className={styles.message}>{t('description.single_sign_on_enabled')}</div>
|
||||
)}
|
||||
|
||||
{!showSingleSignOn && (
|
||||
{!showSingleSignOnForm && (
|
||||
<PasswordInputField
|
||||
className={styles.inputField}
|
||||
autoComplete="current-password"
|
||||
|
@ -124,7 +126,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
{isForgotPasswordEnabled && !showSingleSignOn && (
|
||||
{isForgotPasswordEnabled && !showSingleSignOnForm && (
|
||||
<ForgotPasswordLink
|
||||
className={styles.link}
|
||||
identifier={watch('identifier').type}
|
||||
|
@ -134,8 +136,8 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
|
||||
<Button
|
||||
name="submit"
|
||||
title={showSingleSignOn ? 'action.single_sign_on' : 'action.sign_in'}
|
||||
icon={showSingleSignOn ? <LockIcon /> : undefined}
|
||||
title={showSingleSignOnForm ? 'action.single_sign_on' : 'action.sign_in'}
|
||||
icon={showSingleSignOnForm ? <LockIcon /> : undefined}
|
||||
htmlType="submit"
|
||||
/>
|
||||
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { SignInMode } from '@logto/schemas';
|
||||
import { useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import LandingPageLayout from '@/Layout/LandingPageLayout';
|
||||
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
|
||||
import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext';
|
||||
import Divider from '@/components/Divider';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { isDevFeaturesEnabled } from '@/constants/env';
|
||||
import SocialSignInList from '@/containers/SocialSignInList';
|
||||
import TermsAndPrivacyLinks from '@/containers/TermsAndPrivacyLinks';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
|
@ -15,25 +17,24 @@ import ErrorPage from '../ErrorPage';
|
|||
import Main from './Main';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const SignIn = () => {
|
||||
const { signInMethods, signUpMethods, socialConnectors, signInMode, singleSignOnEnabled } =
|
||||
useSieMethods();
|
||||
const SignInFooters = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!signInMode) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
const { signInMethods, signUpMethods, socialConnectors, signInMode, singleSignOnEnabled } =
|
||||
useSieMethods();
|
||||
|
||||
if (signInMode === SignInMode.Register) {
|
||||
return <Navigate to="/register" />;
|
||||
const { showSingleSignOnForm } = useContext(SingleSignOnFormModeContext);
|
||||
|
||||
/* Hide footers when showing Single Sign On form */
|
||||
if (showSingleSignOnForm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LandingPageLayout title="description.sign_in_to_your_account">
|
||||
<Main signInMethods={signInMethods} socialConnectors={socialConnectors} />
|
||||
<>
|
||||
{
|
||||
// Single Sign On footer TODO: remove the dev feature check once SSO is ready
|
||||
isDevFeaturesEnabled && singleSignOnEnabled && (
|
||||
// Single Sign On footer
|
||||
singleSignOnEnabled && (
|
||||
<div className={styles.singleSignOn}>
|
||||
{t('description.use')}{' '}
|
||||
<TextLink to="/single-sign-on/email" text="action.single_sign_on" />
|
||||
|
@ -58,6 +59,28 @@ const SignIn = () => {
|
|||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SignIn = () => {
|
||||
const { signInMethods, socialConnectors, signInMode } = useSieMethods();
|
||||
|
||||
if (!signInMode) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (signInMode === SignInMode.Register) {
|
||||
return <Navigate to="/register" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LandingPageLayout title="description.sign_in_to_your_account">
|
||||
<SingleSignOnFormModeContextProvider>
|
||||
<Main signInMethods={signInMethods} socialConnectors={socialConnectors} />
|
||||
<SignInFooters />
|
||||
</SingleSignOnFormModeContextProvider>
|
||||
|
||||
<TermsAndPrivacyLinks className={styles.terms} />
|
||||
</LandingPageLayout>
|
||||
);
|
||||
|
|
Loading…
Add table
Reference in a new issue