mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
feat(experience): block email sign-in and register (#4886)
* feat(experience): block email sign-in and register block email sign-in and register if SSO is enabled for that domain address * test(experience): add ut add ut
This commit is contained in:
parent
2c1cf66c6b
commit
e68637f12d
8 changed files with 388 additions and 108 deletions
|
@ -49,82 +49,84 @@ const App = () => {
|
|||
<BrowserRouter>
|
||||
<PageContextProvider>
|
||||
<SettingsProvider>
|
||||
<AppBoundary>
|
||||
<AppInsightsBoundary cloudRole="ui">
|
||||
<Routes>
|
||||
<Route path="sign-in/consent" element={<Consent />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route
|
||||
path="unknown-session"
|
||||
element={<ErrorPage message="error.invalid_session" />}
|
||||
/>
|
||||
<Route path="springboard" element={<Springboard />} />
|
||||
<SingleSignOnContextProvider>
|
||||
<AppBoundary>
|
||||
<AppInsightsBoundary cloudRole="ui">
|
||||
<Routes>
|
||||
<Route path="sign-in/consent" element={<Consent />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route
|
||||
path="unknown-session"
|
||||
element={<ErrorPage message="error.invalid_session" />}
|
||||
/>
|
||||
<Route path="springboard" element={<Springboard />} />
|
||||
|
||||
<Route element={<LoadingLayerProvider />}>
|
||||
{/* Sign-in */}
|
||||
<Route path="sign-in">
|
||||
<Route index element={<SignIn />} />
|
||||
<Route path="password" element={<SignInPassword />} />
|
||||
<Route path="social/:connectorId" element={<SocialSignIn />} />
|
||||
<Route element={<LoadingLayerProvider />}>
|
||||
{/* Sign-in */}
|
||||
<Route path="sign-in">
|
||||
<Route index element={<SignIn />} />
|
||||
<Route path="password" element={<SignInPassword />} />
|
||||
<Route path="social/:connectorId" element={<SocialSignIn />} />
|
||||
</Route>
|
||||
|
||||
{/* Register */}
|
||||
<Route path="register">
|
||||
<Route index element={<Register />} />
|
||||
<Route path="password" element={<RegisterPassword />} />
|
||||
</Route>
|
||||
|
||||
{/* Forgot password */}
|
||||
<Route path="forgot-password">
|
||||
<Route index element={<ForgotPassword />} />
|
||||
<Route path="reset" element={<ResetPassword />} />
|
||||
</Route>
|
||||
|
||||
{/* Passwordless verification code */}
|
||||
<Route path=":flow/verification-code" element={<VerificationCode />} />
|
||||
|
||||
{/* Mfa binding */}
|
||||
<Route path={UserMfaFlow.MfaBinding}>
|
||||
<Route index element={<MfaBinding />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpBinding />} />
|
||||
<Route path={MfaFactor.WebAuthn} element={<WebAuthnBinding />} />
|
||||
<Route path={MfaFactor.BackupCode} element={<BackupCodeBinding />} />
|
||||
</Route>
|
||||
|
||||
{/* Mfa verification */}
|
||||
<Route path={UserMfaFlow.MfaVerification}>
|
||||
<Route index element={<MfaVerification />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpVerification />} />
|
||||
<Route path={MfaFactor.WebAuthn} element={<WebAuthnVerification />} />
|
||||
<Route path={MfaFactor.BackupCode} element={<BackupCodeVerification />} />
|
||||
</Route>
|
||||
|
||||
{/* Continue set up missing profile */}
|
||||
<Route path="continue">
|
||||
<Route path=":method" element={<Continue />} />
|
||||
</Route>
|
||||
|
||||
{/* Social sign-in pages */}
|
||||
<Route path="social">
|
||||
<Route path="link/:connectorId" element={<SocialLinkAccount />} />
|
||||
<Route path="landing/:connectorId" element={<SocialLanding />} />
|
||||
</Route>
|
||||
<Route path="callback/:connectorId" element={<Callback />} />
|
||||
</Route>
|
||||
|
||||
{/* Register */}
|
||||
<Route path="register">
|
||||
<Route index element={<Register />} />
|
||||
<Route path="password" element={<RegisterPassword />} />
|
||||
</Route>
|
||||
{/* Single sign on */}
|
||||
{isDevelopmentFeaturesEnabled && (
|
||||
<Route path={singleSignOnPath}>
|
||||
<Route path="email" element={<SingleSignOnEmail />} />
|
||||
<Route path="connectors" element={<SingleSignOnConnectors />} />
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{/* Forgot password */}
|
||||
<Route path="forgot-password">
|
||||
<Route index element={<ForgotPassword />} />
|
||||
<Route path="reset" element={<ResetPassword />} />
|
||||
</Route>
|
||||
|
||||
{/* Passwordless verification code */}
|
||||
<Route path=":flow/verification-code" element={<VerificationCode />} />
|
||||
|
||||
{/* Mfa binding */}
|
||||
<Route path={UserMfaFlow.MfaBinding}>
|
||||
<Route index element={<MfaBinding />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpBinding />} />
|
||||
<Route path={MfaFactor.WebAuthn} element={<WebAuthnBinding />} />
|
||||
<Route path={MfaFactor.BackupCode} element={<BackupCodeBinding />} />
|
||||
</Route>
|
||||
|
||||
{/* Mfa verification */}
|
||||
<Route path={UserMfaFlow.MfaVerification}>
|
||||
<Route index element={<MfaVerification />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpVerification />} />
|
||||
<Route path={MfaFactor.WebAuthn} element={<WebAuthnVerification />} />
|
||||
<Route path={MfaFactor.BackupCode} element={<BackupCodeVerification />} />
|
||||
</Route>
|
||||
|
||||
{/* Continue set up missing profile */}
|
||||
<Route path="continue">
|
||||
<Route path=":method" element={<Continue />} />
|
||||
</Route>
|
||||
|
||||
{/* Social sign-in pages */}
|
||||
<Route path="social">
|
||||
<Route path="link/:connectorId" element={<SocialLinkAccount />} />
|
||||
<Route path="landing/:connectorId" element={<SocialLanding />} />
|
||||
</Route>
|
||||
<Route path="callback/:connectorId" element={<Callback />} />
|
||||
<Route path="*" element={<ErrorPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Single sign on */}
|
||||
{isDevelopmentFeaturesEnabled && (
|
||||
<Route path={singleSignOnPath} element={<SingleSignOnContextProvider />}>
|
||||
<Route path="email" element={<SingleSignOnEmail />} />
|
||||
<Route path="connectors" element={<SingleSignOnConnectors />} />
|
||||
</Route>
|
||||
)}
|
||||
|
||||
<Route path="*" element={<ErrorPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AppInsightsBoundary>
|
||||
</AppBoundary>
|
||||
</Routes>
|
||||
</AppInsightsBoundary>
|
||||
</AppBoundary>
|
||||
</SingleSignOnContextProvider>
|
||||
</SettingsProvider>
|
||||
</PageContextProvider>
|
||||
</BrowserRouter>
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import { type SsoConnectorMetadata } from '@logto/schemas';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
|
||||
import SingleSignOnContext, { type SingleSignOnContextType } from './SingleSignOnContext';
|
||||
|
||||
const SingleSignOnContextProvider = () => {
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const SingleSignOnContextProvider = ({ children }: Props) => {
|
||||
const { ssoConnectors } = useSieMethods();
|
||||
const { get, set, remove } = useSessionStorage();
|
||||
const [email, setEmail] = useState<string | undefined>(get(StorageKeys.SsoEmail));
|
||||
|
@ -51,7 +54,7 @@ const SingleSignOnContextProvider = () => {
|
|||
|
||||
return (
|
||||
<SingleSignOnContext.Provider value={singleSignOnContext}>
|
||||
<Outlet />
|
||||
{children}
|
||||
</SingleSignOnContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,10 +5,11 @@ import { useNavigate } from 'react-router-dom';
|
|||
|
||||
import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext';
|
||||
import { getSingleSignOnConnectors } from '@/apis/single-sign-on';
|
||||
import { singleSignOnPath } from '@/constants/env';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
|
||||
const useOnSubmit = () => {
|
||||
const useCheckSingleSignOn = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const request = useApi(getSingleSignOnConnectors);
|
||||
|
@ -29,6 +30,11 @@ const useOnSubmit = () => {
|
|||
setSsoConnectors([]);
|
||||
}, [setEmail, setSsoConnectors]);
|
||||
|
||||
/**
|
||||
* Check if the email is registered with any SSO connectors
|
||||
* @param {string} email
|
||||
* @returns {Promise<boolean>} - true if the email is registered with any SSO connectors
|
||||
*/
|
||||
const onSubmit = useCallback(
|
||||
async (email: string) => {
|
||||
clearContext();
|
||||
|
@ -57,7 +63,8 @@ const useOnSubmit = () => {
|
|||
setSsoConnectors(connectors);
|
||||
setEmail(email);
|
||||
|
||||
navigate('../connectors');
|
||||
navigate(`/${singleSignOnPath}/connectors`);
|
||||
return true;
|
||||
},
|
||||
[
|
||||
availableSsoConnectorsMap,
|
||||
|
@ -78,4 +85,4 @@ const useOnSubmit = () => {
|
|||
};
|
||||
};
|
||||
|
||||
export default useOnSubmit;
|
||||
export default useCheckSingleSignOn;
|
|
@ -1,18 +1,22 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { SignInIdentifier, type SsoConnectorMetadata } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { fireEvent, act, waitFor } from '@testing-library/react';
|
||||
|
||||
import ConfirmModalProvider from '@/Providers/ConfirmModalProvider';
|
||||
import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider';
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
|
||||
import { registerWithUsernamePassword } from '@/apis/interaction';
|
||||
import { sendVerificationCodeApi } from '@/apis/utils';
|
||||
import { singleSignOnPath } from '@/constants/env';
|
||||
import { UserFlow } from '@/types';
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import IdentifierRegisterForm from '.';
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
const getSingleSignOnConnectorsMock = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
|
@ -33,11 +37,25 @@ jest.mock('@/apis/interaction', () => ({
|
|||
registerWithUsernamePassword: jest.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
const renderForm = (signUpMethods: SignInIdentifier[] = [SignInIdentifier.Username]) => {
|
||||
jest.mock('@/apis/single-sign-on', () => ({
|
||||
getSingleSignOnConnectors: () => getSingleSignOnConnectorsMock(),
|
||||
}));
|
||||
|
||||
const renderForm = (
|
||||
signUpMethods: SignInIdentifier[] = [SignInIdentifier.Username],
|
||||
ssoConnectors: SsoConnectorMetadata[] = []
|
||||
) => {
|
||||
return renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
ssoConnectors,
|
||||
}}
|
||||
>
|
||||
<ConfirmModalProvider>
|
||||
<IdentifierRegisterForm signUpMethods={signUpMethods} />
|
||||
<SingleSignOnContextProvider>
|
||||
<IdentifierRegisterForm signUpMethods={signUpMethods} />
|
||||
</SingleSignOnContextProvider>
|
||||
</ConfirmModalProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
@ -282,4 +300,85 @@ describe('<IdentifierRegisterForm />', () => {
|
|||
});
|
||||
}
|
||||
);
|
||||
|
||||
describe('single sign on register form', () => {
|
||||
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 submitButton = getByText('action.create_account');
|
||||
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);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).not.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 () => {
|
||||
getSingleSignOnConnectorsMock.mockRejectedValueOnce([]);
|
||||
|
||||
const { getByText, container } = renderForm([SignInIdentifier.Email], mockSsoConnectors);
|
||||
const submitButton = getByText('action.create_account');
|
||||
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);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id));
|
||||
|
||||
const { getByText, container } = renderForm([SignInIdentifier.Email], mockSsoConnectors);
|
||||
const submitButton = getByText('action.create_account');
|
||||
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);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).toBeCalled();
|
||||
expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
|
||||
import useSendVerificationCode from '@/hooks/use-send-verification-code';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import useRegisterWithUsername from './use-register-with-username';
|
||||
|
||||
// TODO: extract the errorMessage and clear method from useRegisterWithUsername and useSendVerificationCode
|
||||
|
||||
const useOnSubmit = () => {
|
||||
const { ssoConnectors } = useSieMethods();
|
||||
const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn();
|
||||
|
||||
const {
|
||||
errorMessage: usernameRegisterErrorMessage,
|
||||
clearErrorMessage: clearUsernameRegisterErrorMessage,
|
||||
|
@ -34,9 +37,18 @@ const useOnSubmit = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step
|
||||
if (identifier === SignInIdentifier.Email && ssoConnectors.length > 0) {
|
||||
const result = await checkSingleSignOn(value);
|
||||
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await sendVerificationCode({ identifier, value });
|
||||
},
|
||||
[registerWithUsername, sendVerificationCode]
|
||||
[checkSingleSignOn, registerWithUsername, sendVerificationCode, ssoConnectors.length]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
import type { SignIn } from '@logto/schemas';
|
||||
import type { SignIn, SsoConnectorMetadata } from '@logto/schemas';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { fireEvent, act, waitFor } from '@testing-library/react';
|
||||
|
||||
import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider';
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import { mockSignInMethodSettingsTestCases } from '@/__mocks__/logto';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import {
|
||||
mockSignInMethodSettingsTestCases,
|
||||
mockSignInExperienceSettings,
|
||||
mockSsoConnectors,
|
||||
} from '@/__mocks__/logto';
|
||||
import { sendVerificationCodeApi } from '@/apis/utils';
|
||||
import { singleSignOnPath } from '@/constants/env';
|
||||
import { UserFlow } from '@/types';
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
|
@ -18,6 +25,7 @@ jest.mock('i18next', () => ({
|
|||
}));
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
const getSingleSignOnConnectorsMock = jest.fn();
|
||||
|
||||
jest.mock('@/apis/utils', () => ({
|
||||
sendVerificationCodeApi: jest.fn(),
|
||||
|
@ -28,12 +36,27 @@ jest.mock('react-router-dom', () => ({
|
|||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/single-sign-on', () => ({
|
||||
getSingleSignOnConnectors: () => getSingleSignOnConnectorsMock(),
|
||||
}));
|
||||
|
||||
const username = 'foo';
|
||||
const email = 'foo@email.com';
|
||||
const phone = '8573333333';
|
||||
|
||||
const renderForm = (signInMethods: SignIn['methods']) =>
|
||||
renderWithPageContext(<IdentifierSignInForm signInMethods={signInMethods} />);
|
||||
const renderForm = (signInMethods: SignIn['methods'], ssoConnectors: SsoConnectorMetadata[] = []) =>
|
||||
renderWithPageContext(
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
ssoConnectors,
|
||||
}}
|
||||
>
|
||||
<SingleSignOnContextProvider>
|
||||
<IdentifierSignInForm signInMethods={signInMethods} />
|
||||
</SingleSignOnContextProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
describe('IdentifierSignInForm', () => {
|
||||
afterEach(() => {
|
||||
|
@ -152,4 +175,116 @@ 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(
|
||||
mockSignInMethodSettingsTestCases[0]!,
|
||||
mockSsoConnectors
|
||||
);
|
||||
|
||||
const inputField = container.querySelector('input[name="identifier"]');
|
||||
const submitButton = getByText('action.sign_in');
|
||||
|
||||
if (inputField) {
|
||||
act(() => {
|
||||
fireEvent.change(inputField, { target: { value: username } });
|
||||
});
|
||||
}
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
{ pathname: 'password' },
|
||||
{ state: { identifier: SignInIdentifier.Username, value: username } }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 inputField = container.querySelector('input[name="identifier"]');
|
||||
const submitButton = getByText('action.sign_in');
|
||||
|
||||
if (inputField) {
|
||||
act(() => {
|
||||
fireEvent.change(inputField, { target: { value: email } });
|
||||
});
|
||||
}
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
{ pathname: 'password' },
|
||||
{ state: { identifier: SignInIdentifier.Email, value: 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 () => {
|
||||
getSingleSignOnConnectorsMock.mockRejectedValueOnce([]);
|
||||
|
||||
const { getByText, container } = renderForm(
|
||||
mockSignInMethodSettingsTestCases[0]!,
|
||||
mockSsoConnectors
|
||||
);
|
||||
|
||||
const inputField = container.querySelector('input[name="identifier"]');
|
||||
const submitButton = getByText('action.sign_in');
|
||||
|
||||
if (inputField) {
|
||||
act(() => {
|
||||
fireEvent.change(inputField, { target: { value: email } });
|
||||
});
|
||||
}
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).toBeCalled();
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
{ pathname: 'password' },
|
||||
{ state: { identifier: SignInIdentifier.Email, value: email } }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
mockSignInMethodSettingsTestCases[0]!,
|
||||
mockSsoConnectors
|
||||
);
|
||||
|
||||
const inputField = container.querySelector('input[name="identifier"]');
|
||||
const submitButton = getByText('action.sign_in');
|
||||
|
||||
if (inputField) {
|
||||
act(() => {
|
||||
fireEvent.change(inputField, { target: { value: email } });
|
||||
});
|
||||
}
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSingleSignOnConnectorsMock).toBeCalled();
|
||||
expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,11 +3,15 @@ import { SignInIdentifier } from '@logto/schemas';
|
|||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
|
||||
import useSendVerificationCode from '@/hooks/use-send-verification-code';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
const useOnSubmit = (signInMethods: SignIn['methods']) => {
|
||||
const navigate = useNavigate();
|
||||
const { ssoConnectors } = useSieMethods();
|
||||
const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn();
|
||||
|
||||
const signInWithPassword = useCallback(
|
||||
(identifier: SignInIdentifier, value: string) => {
|
||||
|
@ -27,31 +31,49 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
|
|||
onSubmit: sendVerificationCode,
|
||||
} = useSendVerificationCode(UserFlow.SignIn);
|
||||
|
||||
const onSubmit = async (identifier: SignInIdentifier, value: string) => {
|
||||
const method = signInMethods.find((method) => method.identifier === identifier);
|
||||
const onSubmit = useCallback(
|
||||
async (identifier: SignInIdentifier, value: string) => {
|
||||
const method = signInMethods.find((method) => method.identifier === identifier);
|
||||
|
||||
if (!method) {
|
||||
throw new Error(`Cannot find method with identifier type ${identifier}`);
|
||||
}
|
||||
if (!method) {
|
||||
throw new Error(`Cannot find method with identifier type ${identifier}`);
|
||||
}
|
||||
|
||||
const { password, isPasswordPrimary, verificationCode } = method;
|
||||
const { password, isPasswordPrimary, verificationCode } = method;
|
||||
|
||||
if (identifier === SignInIdentifier.Username) {
|
||||
signInWithPassword(identifier, value);
|
||||
if (identifier === SignInIdentifier.Username) {
|
||||
signInWithPassword(identifier, value);
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (password && (isPasswordPrimary || !verificationCode)) {
|
||||
signInWithPassword(identifier, value);
|
||||
// Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step
|
||||
if (identifier === SignInIdentifier.Email && ssoConnectors.length > 0) {
|
||||
const result = await checkSingleSignOn(value);
|
||||
|
||||
return;
|
||||
}
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (verificationCode) {
|
||||
await sendVerificationCode({ identifier, value });
|
||||
}
|
||||
};
|
||||
if (password && (isPasswordPrimary || !verificationCode)) {
|
||||
signInWithPassword(identifier, value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (verificationCode) {
|
||||
await sendVerificationCode({ identifier, value });
|
||||
}
|
||||
},
|
||||
[
|
||||
checkSingleSignOn,
|
||||
sendVerificationCode,
|
||||
signInMethods,
|
||||
signInWithPassword,
|
||||
ssoConnectors.length,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
|
|
|
@ -9,10 +9,10 @@ import ErrorMessage from '@/components/ErrorMessage';
|
|||
import SmartInputField, {
|
||||
type IdentifierInputValue,
|
||||
} from '@/components/InputFields/SmartInputField';
|
||||
import useOnSubmit from '@/hooks/use-check-single-sign-on';
|
||||
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
import useOnSubmit from './use-on-submit';
|
||||
|
||||
type FormState = {
|
||||
identifier: IdentifierInputValue;
|
||||
|
|
Loading…
Add table
Reference in a new issue