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;