0
Fork 0
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:
simeng-li 2023-11-30 18:06:07 +08:00 committed by GitHub
parent a763d9e5a5
commit d6de625754
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 322 additions and 97 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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,
};
};

View file

@ -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,
};
};

View file

@ -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;
}
}

View file

@ -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`);
});
});

View file

@ -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>

View file

@ -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,

View file

@ -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>
);
};

View file

@ -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);

View file

@ -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`);
});
});

View file

@ -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>

View file

@ -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>
);

View file

@ -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"
/>

View file

@ -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>
);