0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(experience): add single sign on switch to the password sign-in form (#4898)

* feat(experience): add single sign on switch to the password sign-in form

add single sign on switch to the password sign-in form

* feat(experience): add single sign-on message

add single sign-on message
This commit is contained in:
simeng-li 2023-11-20 10:14:33 +08:00 committed by GitHub
parent 57655dfeb7
commit 29040b9c7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 244 additions and 20 deletions

View file

@ -47,11 +47,7 @@ const useCheckSingleSignOn = () => {
}
const connectors = result
?.map((connectorId) =>
availableSsoConnectorsMap.has(connectorId)
? availableSsoConnectorsMap.get(connectorId)
: undefined
)
?.map((connectorId) => availableSsoConnectorsMap.get(connectorId))
// eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific
.filter((connector): connector is SsoConnectorMetadata => Boolean(connector));

View file

@ -24,6 +24,9 @@ const usePasswordSignIn = () => {
'session.invalid_credentials': (error) => {
setErrorMessage(error.message);
},
'session.sso_enabled': (_error) => {
// Hide the toast and do nothing
},
...preSignInErrorHandler,
}),
[preSignInErrorHandler]

View file

@ -10,7 +10,8 @@
.inputField,
.link,
.terms,
.formErrors {
.formErrors,
.message {
margin-bottom: _.unit(4);
}
@ -20,6 +21,10 @@
width: auto;
}
.message {
@include _.text-hint;
}
.formErrors {
margin-left: _.unit(0.5);
margin-top: _.unit(-3);

View file

@ -3,10 +3,12 @@ import { assert } from '@silverhand/essentials';
import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
import { signInWithPasswordIdentifier } from '@/apis/interaction';
import { singleSignOnPath } from '@/constants/env';
import type { SignInExperienceResponse } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
@ -17,12 +19,24 @@ jest.mock('react-device-detect', () => ({
isMobile: true,
}));
const mockedNavigate = jest.fn();
const getSingleSignOnConnectorsMock = jest.fn();
jest.mock('i18next', () => ({
...jest.requireActual('i18next'),
language: 'en',
t: (key: string) => key,
}));
jest.mock('@/apis/single-sign-on', () => ({
getSingleSignOnConnectors: (email: string) => getSingleSignOnConnectorsMock(email),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('UsernamePasswordSignInForm', () => {
afterEach(() => {
jest.clearAllMocks();
@ -34,7 +48,9 @@ describe('UsernamePasswordSignInForm', () => {
) =>
renderWithPageContext(
<SettingsProvider settings={{ ...mockSignInExperienceSettings, ...settings }}>
<PasswordSignInForm signInMethods={signInMethods} />
<SingleSignOnContextProvider>
<PasswordSignInForm signInMethods={signInMethods} />
</SingleSignOnContextProvider>
</SettingsProvider>
);
@ -165,4 +181,83 @@ describe('UsernamePasswordSignInForm', () => {
});
});
});
test('should switch to single sign on form when single sign on is enabled for a give email', async () => {
const { getByText, queryByText, container } = renderPasswordSignInForm(
[SignInIdentifier.Username, SignInIdentifier.Email],
{
ssoConnectors: mockSsoConnectors,
}
);
const passwordFormAssertion = () => {
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
};
const singleSignOnFormAssertion = () => {
expect(container.querySelector('input[name="password"]')).toBeNull();
expect(queryByText('action.sign_in')).toBeNull();
expect(queryByText('action.single_sign_on')).not.toBeNull();
};
const identifierInput = container.querySelector('input[name="identifier"]');
assert(identifierInput, new Error('identifier input should exist'));
// Default
passwordFormAssertion();
// Username
act(() => {
fireEvent.change(identifierInput, { target: { value: 'foo' } });
});
passwordFormAssertion();
// Invalid email
act(() => {
fireEvent.change(identifierInput, { target: { value: 'foo@l' } });
});
passwordFormAssertion();
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
// Valid email with empty response
const email = 'foo@logto.io';
getSingleSignOnConnectorsMock.mockResolvedValueOnce([]);
act(() => {
fireEvent.change(identifierInput, { target: { value: email } });
});
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).toBeCalledWith(email);
});
passwordFormAssertion();
// Valid email with response
const email2 = 'foo@bar.io';
getSingleSignOnConnectorsMock.mockClear();
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id));
act(() => {
fireEvent.change(identifierInput, { target: { value: email2 } });
});
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).toBeCalledWith(email2);
});
await waitFor(() => {
singleSignOnFormAssertion();
});
const submitButton = getByText('action.single_sign_on');
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`);
});
});
});

View file

@ -14,6 +14,7 @@ import { useForgotPasswordSettings } from '@/hooks/use-sie';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
import useSingleSignOnWatch from './use-single-sign-on-watch';
type Props = {
className?: string;
@ -22,7 +23,7 @@ type Props = {
signInMethods: SignInIdentifier[];
};
type FormState = {
export type FormState = {
identifier: IdentifierInputValue;
password: string;
};
@ -47,11 +48,18 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
},
});
const { showSingleSignOn, navigateToSingleSignOn } = useSingleSignOnWatch(control);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();
void handleSubmit(async ({ identifier: { type, value }, password }) => {
if (showSingleSignOn) {
navigateToSingleSignOn();
return;
}
if (!type) {
return;
}
@ -62,7 +70,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
});
})(event);
},
[clearErrorMessage, handleSubmit, onSubmit]
[clearErrorMessage, handleSubmit, navigateToSingleSignOn, onSubmit, showSingleSignOn]
);
useEffect(() => {
@ -98,19 +106,24 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
/>
)}
/>
{showSingleSignOn && (
<div className={styles.message}>{t('description.single_sign_on_enabled')}</div>
)}
<PasswordInputField
className={styles.inputField}
autoComplete="current-password"
placeholder={t('input.password')}
isDanger={!!errors.password}
errorMessage={errors.password?.message}
{...register('password', { required: t('error.password_required') })}
/>
{!showSingleSignOn && (
<PasswordInputField
className={styles.inputField}
autoComplete="current-password"
placeholder={t('input.password')}
isDanger={!!errors.password}
errorMessage={errors.password?.message}
{...register('password', { required: t('error.password_required') })}
/>
)}
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{isForgotPasswordEnabled && (
{isForgotPasswordEnabled && !showSingleSignOn && (
<ForgotPasswordLink
className={styles.link}
identifier={watch('identifier').type}
@ -118,7 +131,11 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
/>
)}
<Button name="submit" title="action.sign_in" htmlType="submit" />
<Button
name="submit"
title={showSingleSignOn ? 'action.single_sign_on' : 'action.sign_in'}
htmlType="submit"
/>
<input hidden type="submit" />
</form>

View file

@ -0,0 +1,92 @@
import { SignInIdentifier, type SsoConnectorMetadata } from '@logto/schemas';
import { useEffect, useState, useCallback, useContext } from 'react';
import { type Control, useWatch } from 'react-hook-form';
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 { validateEmail } from '@/utils/form';
import type { FormState } from './index';
const useSingleSignOnWatch = (control: Control<FormState>) => {
const navigate = useNavigate();
const { setEmail, setSsoConnectors, availableSsoConnectorsMap } = useContext(SingleSignOnContext);
const [showSingleSignOn, setShowSingleSignOn] = useState(false);
const request = useApi(getSingleSignOnConnectors);
const isSsoEnabled = availableSsoConnectorsMap.size > 0;
const identifierInput = useWatch({
control,
name: 'identifier',
});
/**
* Silently check if the email is registered with any SSO connectors
*/
const fetchSsoConnectors = useCallback(
async (email: string) => {
const [, result] = await request(email);
if (!result) {
return false;
}
const connectors = result
.map((connectorId) => availableSsoConnectorsMap.get(connectorId))
// eslint-disable-next-line unicorn/prefer-native-coercion-functions -- make the type more specific
.filter((connector): connector is SsoConnectorMetadata => Boolean(connector));
if (connectors.length === 0) {
return false;
}
setSsoConnectors(connectors);
setEmail(email);
return true;
},
[availableSsoConnectorsMap, request, setEmail, setSsoConnectors]
);
const navigateToSingleSignOn = useCallback(() => {
navigate(`/${singleSignOnPath}/connectors`);
}, [navigate]);
useEffect(() => {
if (!isSsoEnabled) {
return;
}
const { type, value } = identifierInput;
if (type !== SignInIdentifier.Email) {
setShowSingleSignOn(false);
return;
}
// Will throw an error if the value is not a valid email
if (validateEmail(value)) {
setShowSingleSignOn(false);
return;
}
// Add a debouncing delay to avoid unnecessary API calls
const handler = setTimeout(async () => {
setShowSingleSignOn(await fetchSsoConnectors(value));
}, 300);
return () => {
clearTimeout(handler);
};
}, [fetchSsoConnectors, identifierInput, isSsoEnabled]);
return {
showSingleSignOn,
navigateToSingleSignOn,
};
};
export default useSingleSignOnWatch;

View file

@ -75,6 +75,7 @@ const description = {
single_sign_on_email_form: 'Gib deine Unternehmens-E-Mail-Adresse ein.',
single_sign_on_connectors_list:
'Ihr Unternehmen hat Single Sign-On für das E-Mail-Konto {{email}} aktiviert. Sie können sich weiterhin mit den folgenden SSO-Anbietern anmelden.',
single_sign_on_enabled: 'Single Sign-On ist für dieses Konto aktiviert',
};
export default Object.freeze(description);

View file

@ -72,6 +72,7 @@ const description = {
single_sign_on_email_form: 'Enter your enterprise email address',
single_sign_on_connectors_list:
'Your enterprise has enabled Single Sign-On for the email account {{email}}. You can continue to sign in with the following SSO providers.',
single_sign_on_enabled: 'Single Sign-On is enabled for this account',
};
export default Object.freeze(description);

View file

@ -73,6 +73,8 @@ const description = {
single_sign_on_email_form: 'Ingrese su dirección de correo electrónico corporativo',
single_sign_on_connectors_list:
'Su empresa ha habilitado el inicio de sesión único (Single Sign-On) para la cuenta de correo electrónico {{email}}. Puede continuar iniciando sesión con los siguientes proveedores de SSO.',
single_sign_on_enabled:
'El inicio de sesión único (Single Sign-On) está habilitado para esta cuenta',
};
export default Object.freeze(description);

View file

@ -75,6 +75,7 @@ const description = {
single_sign_on_email_form: "Entrez votre adresse e-mail d'entreprise",
single_sign_on_connectors_list:
'Votre entreprise a activé la connexion unique (Single Sign-On) pour le compte email {{email}}. Vous pouvez continuer à vous connecter avec les fournisseurs SSO suivants.',
single_sign_on_enabled: 'La connexion unique (Single Sign-On) est activée pour ce compte',
};
export default Object.freeze(description);

View file

@ -71,6 +71,7 @@ const description = {
single_sign_on_email_form: 'Inserisci il tuo indirizzo email aziendale',
single_sign_on_connectors_list:
"La tua azienda ha abilitato il Single Sign-On per l'account email {{email}}. Puoi continuare ad accedere con i seguenti fornitori di SSO.",
single_sign_on_enabled: 'Il Single Sign-On è abilitato per questo account',
};
export default Object.freeze(description);

View file

@ -71,6 +71,7 @@ const description = {
single_sign_on_email_form: '企業のメールアドレスを入力してください',
single_sign_on_connectors_list:
'あなたの企業は、メールアカウント{{email}}に対してシングルサインオンを有効にしました。以下のSSOプロバイダーを使用してサインインを続けることができます。',
single_sign_on_enabled: 'このアカウントではシングル サインオンが有効になっています',
};
export default Object.freeze(description);

View file

@ -66,6 +66,7 @@ const description = {
single_sign_on_email_form: '기업 이메일 주소를 입력하세요',
single_sign_on_connectors_list:
'귀하의 기업은 {{email}} 이메일 계정에 대해 Single Sign-On을 활성화했습니다. 다음 SSO 제공업체를 사용하여 로그인을 계속할 수 있습니다.',
single_sign_on_enabled: '이 계정에는 Single Sign-On이 활성화되어 있습니다.',
};
export default Object.freeze(description);

View file

@ -71,6 +71,7 @@ const description = {
single_sign_on_email_form: 'Wpisz swój służbowy adres email',
single_sign_on_connectors_list:
'Twoja firma włączyła jednokrotne logowanie dla konta e-mail {{email}}. Możesz kontynuować logowanie za pomocą następujących dostawców SSO.',
single_sign_on_enabled: 'To konto ma włączone jednokrotne logowanie.',
};
export default Object.freeze(description);

View file

@ -70,6 +70,7 @@ const description = {
single_sign_on_email_form: 'Insira o endereço de e-mail corporativo',
single_sign_on_connectors_list:
'Sua empresa ativou o Single Sign-On para a conta de email {{email}}. Você pode continuar a fazer login com os seguintes provedores de SSO.',
single_sign_on_enabled: 'Esta conta tem Single Sign-On ativado.',
};
export default Object.freeze(description);

View file

@ -70,6 +70,7 @@ const description = {
single_sign_on_email_form: 'Insira o endereço de email corporativo',
single_sign_on_connectors_list:
'A sua empresa ativou o Single Sign-On para a conta de email {{email}}. Pode continuar a iniciar sessão com os seguintes fornecedores de SSO.',
single_sign_on_enabled: 'Esta conta tem o Single Sign-On ativado.',
};
export default Object.freeze(description);

View file

@ -74,6 +74,7 @@ const description = {
single_sign_on_email_form: 'Введите корпоративный адрес электронной почты',
single_sign_on_connectors_list:
'Ваше предприятие включило функцию единого входа для электронной почты {{email}}. Вы можете продолжить вход в систему с помощью следующих провайдеров SSO.',
single_sign_on_enabled: 'Единый вход в систему включен для этой учетной записи',
};
export default Object.freeze(description);

View file

@ -70,6 +70,7 @@ const description = {
single_sign_on_email_form: 'Kurumsal e-posta adresinizi girin',
single_sign_on_connectors_list:
'Şirketiniz, {{email}} e-posta hesabı için Tekli Oturum Açmayı (Single Sign-On) etkinleştirdi. Aşağıdaki SSO sağlayıcıları ile oturum açmaya devam edebilirsiniz.',
single_sign_on_enabled: 'Bu hesapta Tekli Oturum Açma etkinleştirildi.',
};
export default Object.freeze(description);

View file

@ -62,6 +62,7 @@ const description = {
single_sign_on_email_form: '输入你的企业电子邮件地址',
single_sign_on_connectors_list:
'你的企业已为电子邮件账户{{email}}启用了单点登录。你可以继续使用以下SSO提供商进行登录。',
single_sign_on_enabled: '该帐户已启用单点登录',
};
export default Object.freeze(description);

View file

@ -62,6 +62,7 @@ const description = {
single_sign_on_email_form: '輸入你的企業電子郵件地址',
single_sign_on_connectors_list:
'您的企業已為電郵賬戶{{email}}啟用單一登入。您可以繼續使用以下的SSO供應商登入。',
single_sign_on_enabled: '該帳戶已啟用單一登入',
};
export default Object.freeze(description);

View file

@ -62,6 +62,7 @@ const description = {
single_sign_on_email_form: '輸入你的企業電子郵件地址',
single_sign_on_connectors_list:
'您的企業已為電子郵件帳戶{{email}}啟用單一登入。您可以繼續使用以下的SSO供應商登入。',
single_sign_on_enabled: '該帳戶已啟用單一登入',
};
export default Object.freeze(description);