diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 26e74007e..5626b5597 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -37,6 +37,7 @@ const translation = { loading: 'Loading...', redirecting: 'Redirecting...', agree_with_terms: 'I have read and agree to the ', + agree_with_terms_modal: 'Please read the {{terms}} and then agree the box first.', terms_of_use: 'Terms of Use', create_account: 'Create Account', forgot_password: 'Forgot Password?', diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 28b17f6e8..50cd768b5 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -39,6 +39,7 @@ const translation = { loading: '读取中...', redirecting: '页面跳转中...', agree_with_terms: '我已阅读并同意 ', + agree_with_terms_modal: 'Please read the {{terms}} and then agree the box first.', terms_of_use: '使用条款', create_account: '创建账号', forgot_password: '忘记密码?', diff --git a/packages/ui/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx b/packages/ui/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx new file mode 100644 index 000000000..cacae1502 --- /dev/null +++ b/packages/ui/src/__mocks__/RenderWithPageContext/SettingsProvider.tsx @@ -0,0 +1,21 @@ +import { useContext, useEffect, ReactElement } from 'react'; + +import { PageContext } from '@/hooks/use-page-context'; +import { SignInExperienceSettings } from '@/types'; + +type Props = { + settings: SignInExperienceSettings; + children: ReactElement; +}; + +const SettingsProvider = ({ settings, children }: Props) => { + const { setExperienceSettings } = useContext(PageContext); + + useEffect(() => { + setExperienceSettings(settings); + }, [setExperienceSettings, settings]); + + return children; +}; + +export default SettingsProvider; diff --git a/packages/ui/src/__mocks__/logto.tsx b/packages/ui/src/__mocks__/logto.tsx index 30360b666..daaa5f940 100644 --- a/packages/ui/src/__mocks__/logto.tsx +++ b/packages/ui/src/__mocks__/logto.tsx @@ -1,3 +1,8 @@ +import { Language } from '@logto/phrases'; +import { BrandingStyle, SignInExperience, SignInMethodState } from '@logto/schemas'; + +import { SignInExperienceSettings } from '@/types'; + export const appLogo = 'https://avatars.githubusercontent.com/u/88327661?s=200&v=4'; export const appHeadline = 'Build user identity in a modern way'; export const socialConnectors = [ @@ -38,29 +43,38 @@ export const socialConnectors = [ }, ]; -export const mockSignInExperience = { +export const mockSignInExperience: SignInExperience = { id: 'foo', branding: { primaryColor: '#000', isDarkModeEnabled: true, darkPrimaryColor: '#fff', - style: 'Logo_Slogan', + style: BrandingStyle.Logo_Slogan, logoUrl: 'http://logto.png', slogan: 'logto', }, termsOfUse: { - enabled: false, + enabled: true, + contentUrl: 'http://terms.of.use', }, languageInfo: { autoDetect: true, - fallbackLanguage: 'en', - fixedLanguage: 'zh-cn', + fallbackLanguage: Language.English, + fixedLanguage: Language.Chinese, }, signInMethods: { - username: 'primary', - email: 'secondary', - sms: 'secondary', - social: 'secondary', + username: SignInMethodState.Primary, + email: SignInMethodState.Secondary, + sms: SignInMethodState.Secondary, + social: SignInMethodState.Secondary, }, socialSignInConnectorIds: ['github', 'facebook'], }; + +export const mockSignInExperienceSettings: SignInExperienceSettings = { + branding: mockSignInExperience.branding, + termsOfUse: mockSignInExperience.termsOfUse, + languageInfo: mockSignInExperience.languageInfo, + primarySignInMethod: 'username', + secondarySignInMethods: ['email', 'sms', 'social'], +}; diff --git a/packages/ui/src/components/ConfirmModal/index.tsx b/packages/ui/src/components/ConfirmModal/index.tsx index 79ba734ca..bf229939c 100644 --- a/packages/ui/src/components/ConfirmModal/index.tsx +++ b/packages/ui/src/components/ConfirmModal/index.tsx @@ -36,6 +36,7 @@ const ConfirmModal = ({ className={classNames(modalStyles.modal, className)} overlayClassName={modalStyles.overlay} parentSelector={() => document.querySelector('main') ?? document.body} + ariaHideApp={false} >
{children}
diff --git a/packages/ui/src/components/TermsOfUse/index.test.tsx b/packages/ui/src/components/TermsOfUse/index.test.tsx index 40ad1777c..9fb9b5572 100644 --- a/packages/ui/src/components/TermsOfUse/index.test.tsx +++ b/packages/ui/src/components/TermsOfUse/index.test.tsx @@ -1,4 +1,3 @@ -import { TermsOfUse as TermsOfUseType } from '@logto/schemas'; import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -7,10 +6,7 @@ import TermsOfUse from '.'; describe('Terms of Use', () => { const onChange = jest.fn(); - const termsOfUse: TermsOfUseType = { - enabled: true, - contentUrl: 'http://logto.dev/', - }; + const contentUrl = 'http://logto.dev/'; const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' }); const prefix = t('description.agree_with_terms'); @@ -20,7 +16,7 @@ describe('Terms of Use', () => { it('render Terms of User checkbox', () => { const { getByText, container } = render( - + ); const element = getByText(prefix); @@ -33,15 +29,7 @@ describe('Terms of Use', () => { expect(linkElement).not.toBeNull(); if (linkElement) { - expect(linkElement.href).toEqual(termsOfUse.contentUrl); + expect(linkElement.href).toEqual(contentUrl); } }); - - it('render null with disabled terms', () => { - const { container } = render( - - ); - - expect(container.children).toHaveLength(0); - }); }); diff --git a/packages/ui/src/components/TermsOfUse/index.tsx b/packages/ui/src/components/TermsOfUse/index.tsx index ec3f821f7..98d684b13 100644 --- a/packages/ui/src/components/TermsOfUse/index.tsx +++ b/packages/ui/src/components/TermsOfUse/index.tsx @@ -1,8 +1,7 @@ -import { TermsOfUse as TermsOfUseType } from '@logto/schemas'; +import classNames from 'classnames'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import ErrorMessage, { ErrorType } from '@/components/ErrorMessage'; import { RadioButtonIcon } from '@/components/Icons'; import TextLink from '@/components/TextLink'; @@ -11,46 +10,39 @@ import * as styles from './index.module.scss'; type Props = { name: string; className?: string; - termsOfUse: TermsOfUseType; + termsUrl: string; isChecked?: boolean; - error?: ErrorType; onChange: (checked: boolean) => void; }; -const TermsOfUse = ({ name, className, termsOfUse, isChecked, error, onChange }: Props) => { +const TermsOfUse = ({ name, className, termsUrl, isChecked, onChange }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' }); - if (!termsOfUse.enabled || !termsOfUse.contentUrl) { - return null; - } - const prefix = t('description.agree_with_terms'); return ( -
-
{ - onChange(!isChecked); - }} - > - - -
- {prefix} - { - // Prevent above parent onClick event being triggered - event.stopPropagation(); - }} - /> -
+
{ + onChange(!isChecked); + }} + > + + +
+ {prefix} + { + // Prevent above parent onClick event being triggered + event.stopPropagation(); + }} + />
- {error && }
); }; diff --git a/packages/ui/src/components/TermsOfUseModal/index.test.tsx b/packages/ui/src/components/TermsOfUseModal/index.test.tsx new file mode 100644 index 000000000..b6a92295a --- /dev/null +++ b/packages/ui/src/components/TermsOfUseModal/index.test.tsx @@ -0,0 +1,22 @@ +import { render } from '@testing-library/react'; +import React from 'react'; + +import TermsOfUseModal from '.'; + +describe('TermsOfUseModal', () => { + const onConfirm = jest.fn(); + const onCancel = jest.fn(); + + it('render properly', () => { + const { queryByText } = render( + + ); + + expect(queryByText('description.agree_with_terms_modal')).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/components/TermsOfUseModal/index.tsx b/packages/ui/src/components/TermsOfUseModal/index.tsx new file mode 100644 index 000000000..169718d68 --- /dev/null +++ b/packages/ui/src/components/TermsOfUseModal/index.tsx @@ -0,0 +1,32 @@ +import React, { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import reactStringReplace from 'react-string-replace'; + +import ConfirmModal from '../ConfirmModal'; +import TextLink from '../TextLink'; + +type Props = { + isOpen?: boolean; + onConfirm: () => void; + onClose: () => void; + termsUrl: string; +}; + +const TermsOfUseModal = ({ isOpen = false, termsUrl, onConfirm, onClose }: Props) => { + const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' }); + + const terms = t('description.terms_of_use'); + const content = t('description.agree_with_terms_modal', { terms }); + + const modalContent: ReactNode = reactStringReplace(content, terms, () => ( + + )); + + return ( + + {modalContent} + + ); +}; + +export default TermsOfUseModal; diff --git a/packages/ui/src/components/TextLink/index.tsx b/packages/ui/src/components/TextLink/index.tsx index c30dfae5c..2c7d04f39 100644 --- a/packages/ui/src/components/TextLink/index.tsx +++ b/packages/ui/src/components/TextLink/index.tsx @@ -1,23 +1,21 @@ import classNames from 'classnames'; -import React, { ReactNode } from 'react'; +import React, { ReactNode, AnchorHTMLAttributes } from 'react'; import { TFuncKey, useTranslation } from 'react-i18next'; import * as styles from './index.module.scss'; -export type Props = { +export type Props = AnchorHTMLAttributes & { className?: string; children?: ReactNode; text?: TFuncKey<'translation', 'main_flow'>; - href?: string; type?: 'primary' | 'secondary'; - onClick?: React.MouseEventHandler; }; -const TextLink = ({ className, children, text, href, type = 'primary', onClick }: Props) => { +const TextLink = ({ className, children, text, type = 'primary', ...rest }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' }); return ( - + {children ?? (text ? t(text) : '')} ); diff --git a/packages/ui/src/containers/CreateAccount/index.test.tsx b/packages/ui/src/containers/CreateAccount/index.test.tsx index a4508a55e..829236f3a 100644 --- a/packages/ui/src/containers/CreateAccount/index.test.tsx +++ b/packages/ui/src/containers/CreateAccount/index.test.tsx @@ -131,38 +131,6 @@ describe('', () => { expect(queryByText('passwords_do_not_match')).toBeNull(); }); - test('terms of use not checked should throw', () => { - const { queryByText, getByText, container } = renderWithPageContext(); - const submitButton = getByText('action.create'); - const passwordInput = container.querySelector('input[name="password"]'); - const confirmPasswordInput = container.querySelector('input[name="confirm_password"]'); - const usernameInput = container.querySelector('input[name="username"]'); - - if (usernameInput) { - fireEvent.change(usernameInput, { target: { value: 'username' } }); - } - - if (passwordInput) { - fireEvent.change(passwordInput, { target: { value: '123456' } }); - } - - if (confirmPasswordInput) { - fireEvent.change(confirmPasswordInput, { target: { value: '123456' } }); - } - - fireEvent.click(submitButton); - - expect(queryByText('agree_terms_required')).not.toBeNull(); - - expect(register).not.toBeCalled(); - - // Clear Error - const termsButton = getByText('description.agree_with_terms'); - fireEvent.click(termsButton); - - expect(queryByText('agree_terms_required')).toBeNull(); - }); - test('submit form properly', async () => { const { getByText, container } = renderWithPageContext(); const submitButton = getByText('action.create'); diff --git a/packages/ui/src/containers/CreateAccount/index.tsx b/packages/ui/src/containers/CreateAccount/index.tsx index da74f207c..b7dfa3e18 100644 --- a/packages/ui/src/containers/CreateAccount/index.tsx +++ b/packages/ui/src/containers/CreateAccount/index.tsx @@ -211,9 +211,8 @@ const CreateAccount = ({ className }: Props) => { { setFieldState((state) => ({ ...state, termsAgreement: checked })); }} diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx index 94479eb5f..e425e5396 100644 --- a/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx @@ -50,24 +50,7 @@ describe('', () => { } }); - test('required terms of agreement with error message', () => { - const { queryByText, container, getByText } = renderWithPageContext( - - - - ); - const submitButton = getByText('action.continue'); - const emailInput = container.querySelector('input[name="email"]'); - - if (emailInput) { - fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } }); - } - - fireEvent.click(submitButton); - expect(queryByText('agree_terms_required')).not.toBeNull(); - }); - - test('signin method properly', async () => { + test('should call sign-in method properly', async () => { const { container, getByText } = renderWithPageContext( @@ -90,7 +73,7 @@ describe('', () => { expect(sendSignInEmailPasscode).toBeCalledWith('foo@logto.io'); }); - test('register method properly', async () => { + test('should call register method properly', async () => { const { container, getByText } = renderWithPageContext( diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx index c3b001829..da059036e 100644 --- a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx @@ -140,9 +140,8 @@ const EmailPasswordless = ({ type, className }: Props) => { { setFieldState((state) => ({ ...state, termsAgreement: checked })); }} diff --git a/packages/ui/src/containers/Passwordless/PhonePasswordless.test.tsx b/packages/ui/src/containers/Passwordless/PhonePasswordless.test.tsx index 4b746ebac..9a962e31e 100644 --- a/packages/ui/src/containers/Passwordless/PhonePasswordless.test.tsx +++ b/packages/ui/src/containers/Passwordless/PhonePasswordless.test.tsx @@ -53,24 +53,7 @@ describe('', () => { } }); - test('required terms of agreement with error message', () => { - const { queryByText, container, getByText } = renderWithPageContext( - - - - ); - const submitButton = getByText('action.continue'); - const phoneInput = container.querySelector('input[name="phone"]'); - - if (phoneInput) { - fireEvent.change(phoneInput, { target: { value: phoneNumber } }); - } - - fireEvent.click(submitButton); - expect(queryByText('agree_terms_required')).not.toBeNull(); - }); - - test('signin method properly', async () => { + test('should call sign-in method properly', async () => { const { container, getByText } = renderWithPageContext( @@ -93,7 +76,7 @@ describe('', () => { expect(sendSignInSmsPasscode).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`); }); - test('register method properly', async () => { + test('should call register method properly', async () => { const { container, getByText } = renderWithPageContext( diff --git a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx index de68d4367..8187235b1 100644 --- a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx @@ -143,9 +143,8 @@ const PhonePasswordless = ({ type, className }: Props) => { { setFieldState((state) => ({ ...state, termsAgreement: checked })); }} diff --git a/packages/ui/src/containers/TermsOfUse/index.tsx b/packages/ui/src/containers/TermsOfUse/index.tsx new file mode 100644 index 000000000..7133c6b50 --- /dev/null +++ b/packages/ui/src/containers/TermsOfUse/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import PureTermsOfUse from '@/components/TermsOfUse'; +import TermsOfUseModal from '@/components/TermsOfUseModal'; +import useTerms from '@/hooks/use-terms'; + +type Props = { + className?: string; +}; + +const TermsOfUse = ({ className }: Props) => { + const { termsAgreement, setTermsAgreement, termsSettings, showTermsModal, setShowTermsModal } = + useTerms(); + + if (!termsSettings?.enabled || !termsSettings.contentUrl) { + return null; + } + + return ( + <> + { + setTermsAgreement(checked); + }} + /> + { + setTermsAgreement(true); + setShowTermsModal(false); + }} + onClose={() => { + setShowTermsModal(false); + }} + /> + + ); +}; + +export default TermsOfUse; diff --git a/packages/ui/src/containers/TermsOfUse/intext.test.tsx b/packages/ui/src/containers/TermsOfUse/intext.test.tsx new file mode 100644 index 000000000..c0392dad6 --- /dev/null +++ b/packages/ui/src/containers/TermsOfUse/intext.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; +import { mockSignInExperienceSettings } from '@/__mocks__/logto'; + +import TermsOfUse from '.'; + +describe('TermsOfUse Container', () => { + it('render with empty TermsOfUse settings', () => { + const { queryByText } = renderWithPageContext(); + expect(queryByText('description.agree_with_terms')).toBeNull(); + }); + + it('render with settings', async () => { + const { queryByText } = renderWithPageContext( + + + + ); + + expect(queryByText('description.agree_with_terms')).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/containers/UsernameSignin/index.test.tsx b/packages/ui/src/containers/UsernameSignin/index.test.tsx index dad2be78d..fdc971c4b 100644 --- a/packages/ui/src/containers/UsernameSignin/index.test.tsx +++ b/packages/ui/src/containers/UsernameSignin/index.test.tsx @@ -44,7 +44,6 @@ describe('', () => { fireEvent.click(submitButton); expect(queryByText('required')).toBeNull(); - expect(queryByText('agree_terms_required')).not.toBeNull(); expect(signInBasic).not.toBeCalled(); }); diff --git a/packages/ui/src/containers/UsernameSignin/index.tsx b/packages/ui/src/containers/UsernameSignin/index.tsx index cf7cf484c..8c562adb9 100644 --- a/packages/ui/src/containers/UsernameSignin/index.tsx +++ b/packages/ui/src/containers/UsernameSignin/index.tsx @@ -168,9 +168,8 @@ const UsernameSignin = ({ className }: Props) => { { setFieldState((state) => ({ ...state, termsAgreement: checked })); }} diff --git a/packages/ui/src/hooks/use-terms.ts b/packages/ui/src/hooks/use-terms.ts new file mode 100644 index 000000000..b822c81cc --- /dev/null +++ b/packages/ui/src/hooks/use-terms.ts @@ -0,0 +1,32 @@ +import { useContext, useCallback } from 'react'; + +import { PageContext } from './use-page-context'; + +const useTerms = () => { + const { + termsAgreement, + setTermsAgreement, + showTermsModal, + setShowTermsModal, + experienceSettings, + } = useContext(PageContext); + + const termsValidation = useCallback(() => { + if (termsAgreement) { + return; + } + + setShowTermsModal(true); + }, [setShowTermsModal, termsAgreement]); + + return { + termsSettings: experienceSettings?.termsOfUse, + termsAgreement, + showTermsModal, + termsValidation, + setTermsAgreement, + setShowTermsModal, + }; +}; + +export default useTerms;