diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts
index f13bfb535..8dcbee90d 100644
--- a/packages/phrases/src/locales/en.ts
+++ b/packages/phrases/src/locales/en.ts
@@ -8,6 +8,9 @@ const translation = {
error: 'Username or password is invalid.',
username: 'Username',
password: 'Password',
+ terms_of_use: 'Terms of Use',
+ terms_agreement_prefix: 'I agree with ',
+ continue_with: 'Continue With',
},
register: {
create_account: 'Create an Account',
diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts
index f5f1f3a5b..8e2507dd3 100644
--- a/packages/phrases/src/locales/zh-cn.ts
+++ b/packages/phrases/src/locales/zh-cn.ts
@@ -10,6 +10,9 @@ const translation = {
error: '用户名或密码错误。',
username: '用户名',
password: '密码',
+ terms_of_use: '用户协议',
+ terms_agreement_prefix: '登录即表明您已经同意',
+ continue_with: '更多',
},
register: {
create_account: '创建新账户',
diff --git a/packages/ui/src/assets/icons/radio-button.svg b/packages/ui/src/assets/icons/radio-button.svg
new file mode 100644
index 000000000..324930ca5
--- /dev/null
+++ b/packages/ui/src/assets/icons/radio-button.svg
@@ -0,0 +1,9 @@
+
diff --git a/packages/ui/src/components/AppContent/index.module.scss b/packages/ui/src/components/AppContent/index.module.scss
index c0cfe8fc0..0fae2cefb 100644
--- a/packages/ui/src/components/AppContent/index.module.scss
+++ b/packages/ui/src/components/AppContent/index.module.scss
@@ -74,15 +74,20 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
--color-control-focus: #{$color-primary-tint-60};
--color-control-action: #{$color-neutral-70};
--color-control-action-focus: #{$color-primary-tint-70};
+ --color-checkbox-border: #{$color-neutral-30};
+ --color-divider: #dbdbdb;
/* Font Color */
--color-font-primary: #{$color-neutral-100};
--color-font-secondary: #444;
--color-font-placeholder: #aaa;
+ --color-font-divider: #bbb;
--color-font-button-text: #{$color-neutral-0};
--color-font-button-text-active: #{rgba($color-neutral-0, 0.4)};
--color-font-secondary-active: #{$color-neutral-70};
--color-font-secondary-disabled: #{rgba($color-neutral-100, 0.4)};
+ --color-font-link: #{$color-primary};
+ --color-font-link-secondary: #{$color-neutral-70};
/* ===== Legacy Styling ===== */
--color-heading: #333;
@@ -98,15 +103,15 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
.mobile {
--font-title: 600 32px/40px #{$font-family};
- --font-heading-2: 400 18px/22px #{$font-family};
+ --font-heading-2: 500 18px/22px #{$font-family};
--font-heading-2-bold: 600 18px/22px #{$font-family};
--font-control: 500 18px/20px #{$font-family};
--font-button-text: 600 20px/24px #{$font-family};
-
+ --font-body: 400 16px/20px #{$font-family};
+ --font-body-bold: 600 16px/20px #{$font-family};
+ --font-body-small: 400 14px/18px #{$font-family};
/* ===== Legacy Styling ===== */
--font-headline: 600 40px/56px #{$font-family};
--font-heading-1: 600 28px/39px #{$font-family};
--font-heading-3: 600 16px/22.4px #{$font-family};
- --font-body: 400 12px/16px #{$font-family};
- --font-body-bold: 500 12px/16px #{$font-family};
}
diff --git a/packages/ui/src/components/Divider/index.module.scss b/packages/ui/src/components/Divider/index.module.scss
new file mode 100644
index 000000000..b7270e8f8
--- /dev/null
+++ b/packages/ui/src/components/Divider/index.module.scss
@@ -0,0 +1,24 @@
+@use '@/scss/underscore' as _;
+
+
+.divider {
+ @include _.flex-row;
+ font: var(--font-body);
+ color: var(--color-font-divider);
+ margin: _.unit(4) 0;
+ width: 100%;
+
+ .line {
+ flex: 1;
+ height: 1px;
+ background: var(--color-divider);
+
+ &:first-child {
+ margin-right: _.unit(4);
+ }
+
+ &:last-child {
+ margin-left: _.unit(4);
+ }
+ }
+}
diff --git a/packages/ui/src/components/Divider/index.test.tsx b/packages/ui/src/components/Divider/index.test.tsx
new file mode 100644
index 000000000..30d69919f
--- /dev/null
+++ b/packages/ui/src/components/Divider/index.test.tsx
@@ -0,0 +1,14 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import Divider from '.';
+
+describe('Divider', () => {
+ const { t } = useTranslation();
+
+ it('render with content', () => {
+ const { queryByText } = render();
+ expect(queryByText(t('sign_in.continue_with'))).not.toBeNull();
+ });
+});
diff --git a/packages/ui/src/components/Divider/index.tsx b/packages/ui/src/components/Divider/index.tsx
new file mode 100644
index 000000000..15c5fad23
--- /dev/null
+++ b/packages/ui/src/components/Divider/index.tsx
@@ -0,0 +1,25 @@
+import { I18nKey } from '@logto/phrases';
+import classNames from 'classnames';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import * as styles from './index.module.scss';
+
+type Props = {
+ className?: string;
+ label?: I18nKey;
+};
+
+const Divider = ({ className, label }: Props) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {label && t(label)}
+
+
+ );
+};
+
+export default Divider;
diff --git a/packages/ui/src/components/ErrorMessage/index.module.scss b/packages/ui/src/components/ErrorMessage/index.module.scss
new file mode 100644
index 000000000..5d1f1c60b
--- /dev/null
+++ b/packages/ui/src/components/ErrorMessage/index.module.scss
@@ -0,0 +1,6 @@
+@use '@/scss/underscore' as _;
+
+.error {
+ font: var(--font-body-small);
+ color: var(--color-error);
+}
diff --git a/packages/ui/src/components/ErrorMessage/index.tsx b/packages/ui/src/components/ErrorMessage/index.tsx
new file mode 100644
index 000000000..8d930f78c
--- /dev/null
+++ b/packages/ui/src/components/ErrorMessage/index.tsx
@@ -0,0 +1,24 @@
+import { LogtoErrorCode, LogtoErrorI18nKey } from '@logto/phrases';
+import classNames from 'classnames';
+import React, { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import * as styles from './index.module.scss';
+
+export type Props = {
+ errorCode?: LogtoErrorCode;
+ className?: string;
+ children?: ReactNode;
+};
+
+const ErrorMessage = ({ errorCode, className, children }: Props) => {
+ const { i18n } = useTranslation();
+
+ return (
+
+ {children ?? (errorCode ? i18n.t(`errors:${errorCode}`) : ``)}
+
+ );
+};
+
+export default ErrorMessage;
diff --git a/packages/ui/src/components/Icons/RadioButtonIcon.tsx b/packages/ui/src/components/Icons/RadioButtonIcon.tsx
new file mode 100644
index 000000000..603cfd5a5
--- /dev/null
+++ b/packages/ui/src/components/Icons/RadioButtonIcon.tsx
@@ -0,0 +1,16 @@
+import React, { SVGProps } from 'react';
+
+import RadioButton from '@/assets/icons/radio-button.svg';
+
+const RadioButtonIcon = ({
+ checked,
+ ...props
+}: SVGProps & { checked?: boolean }) => {
+ return (
+
+ );
+};
+
+export default RadioButtonIcon;
diff --git a/packages/ui/src/components/MessageBox/index.module.scss b/packages/ui/src/components/MessageBox/index.module.scss
deleted file mode 100644
index c6586256b..000000000
--- a/packages/ui/src/components/MessageBox/index.module.scss
+++ /dev/null
@@ -1,13 +0,0 @@
-@use '@/scss/underscore' as _;
-
-.messageBox {
- font: var(--font-body-bold);
- padding: _.unit(2) _.unit(5);
- border-radius: _.unit();
-
- &.error {
- color: var(--color-error);
- background: var(--color-error-background);
- border: 1px solid var(--color-error-border);
- }
-}
diff --git a/packages/ui/src/components/MessageBox/index.tsx b/packages/ui/src/components/MessageBox/index.tsx
deleted file mode 100644
index 698a96ea6..000000000
--- a/packages/ui/src/components/MessageBox/index.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import classNames from 'classnames';
-import React, { ReactNode } from 'react';
-
-import * as styles from './index.module.scss';
-
-export type Props = {
- className?: string;
- children: ReactNode;
-};
-
-const MessageBox = ({ className, children }: Props) => {
- return {children}
;
-};
-
-export default MessageBox;
diff --git a/packages/ui/src/components/TermsOfUse/index.module.scss b/packages/ui/src/components/TermsOfUse/index.module.scss
new file mode 100644
index 000000000..9595343d3
--- /dev/null
+++ b/packages/ui/src/components/TermsOfUse/index.module.scss
@@ -0,0 +1,22 @@
+@use '@/scss/underscore' as _;
+
+.terms {
+ @include _.flex-row;
+
+ input[type='checkbox'] {
+ appearance: none;
+ position: absolute;
+ margin: 0;
+ width: 0;
+ height: 0;
+ }
+}
+
+.radioButton {
+ margin-right: _.unit(2);
+ transform: scale(0.8);
+}
+
+.content {
+ @include _.text-hint;
+}
diff --git a/packages/ui/src/components/TermsOfUse/index.test.tsx b/packages/ui/src/components/TermsOfUse/index.test.tsx
new file mode 100644
index 000000000..069a9a712
--- /dev/null
+++ b/packages/ui/src/components/TermsOfUse/index.test.tsx
@@ -0,0 +1,47 @@
+import { TermsOfUse as TermsOfUseType } from '@logto/schemas';
+import { render, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import TermsOfUse from '.';
+
+describe('Terms of Use', () => {
+ const onChange = jest.fn();
+ const termsOfUse: TermsOfUseType = {
+ enabled: true,
+ contentUrl: 'http://logto.dev/',
+ };
+ const { t } = useTranslation();
+ const prefix = t('sign_in.terms_agreement_prefix');
+
+ beforeEach(() => {
+ onChange.mockClear();
+ });
+
+ it('render Terms of User checkbox', () => {
+ const { getByText, container } = render(
+
+ );
+
+ const element = getByText(prefix);
+
+ fireEvent.click(element);
+
+ expect(onChange).toBeCalledWith(true);
+
+ const linkElement = container.querySelector('a');
+ expect(linkElement).not.toBeNull();
+
+ if (linkElement) {
+ expect(linkElement.href).toEqual(termsOfUse.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
new file mode 100644
index 000000000..60106687d
--- /dev/null
+++ b/packages/ui/src/components/TermsOfUse/index.tsx
@@ -0,0 +1,52 @@
+import { TermsOfUse as TermsOfUseType } from '@logto/schemas';
+import classNames from 'classnames';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import RadioButtonIcon from '@/components/Icons/RadioButtonIcon';
+import TextLink from '@/components/TextLink';
+
+import * as styles from './index.module.scss';
+
+type Props = {
+ name: string;
+ className?: string;
+ termsOfUse: TermsOfUseType;
+ isChecked?: boolean;
+ onChange: (checked: boolean) => void;
+};
+
+const TermsOfUse = ({ name, className, termsOfUse, isChecked, onChange }: Props) => {
+ const { t } = useTranslation();
+
+ if (!termsOfUse.enabled || !termsOfUse.contentUrl) {
+ return null;
+ }
+
+ const prefix = t('sign_in.terms_agreement_prefix');
+
+ return (
+ {
+ onChange(!isChecked);
+ }}
+ >
+
+
+
+ {prefix}
+ {
+ event.stopPropagation();
+ }}
+ />
+
+
+ );
+};
+
+export default TermsOfUse;
diff --git a/packages/ui/src/components/TextLink/index.module.scss b/packages/ui/src/components/TextLink/index.module.scss
index 0795288b6..565024e2e 100644
--- a/packages/ui/src/components/TextLink/index.module.scss
+++ b/packages/ui/src/components/TextLink/index.module.scss
@@ -1,12 +1,18 @@
@use '@/scss/underscore' as _;
.link {
- color: var(--color-button-background);
- font: var(--font-body-bold);
transition: var(--transition-default-control);
cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
- &:hover {
- color: var(--color-button-background-hover);
+ &.primary {
+ color: var(--color-font-link);
+ font: var(--font-body-bold);
+ text-decoration: none;
+ }
+
+ &.secondary {
+ color: var(--color-font-link-secondary);
+ font: var(--font-body-small);
}
}
diff --git a/packages/ui/src/components/TextLink/index.test.tsx b/packages/ui/src/components/TextLink/index.test.tsx
new file mode 100644
index 000000000..922cee4ff
--- /dev/null
+++ b/packages/ui/src/components/TextLink/index.test.tsx
@@ -0,0 +1,18 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import TextLink from '.';
+
+describe('TextLink', () => {
+ it('render with children', () => {
+ const { queryByText } = render(foo);
+ expect(queryByText('foo')).not.toBeNull();
+ });
+
+ it('render with i18nKey', () => {
+ const { queryByText } = render();
+ const { t } = useTranslation();
+ expect(queryByText(t('sign_in.action'))).not.toBeNull();
+ });
+});
diff --git a/packages/ui/src/components/TextLink/index.tsx b/packages/ui/src/components/TextLink/index.tsx
index 9808dac18..33b228be8 100644
--- a/packages/ui/src/components/TextLink/index.tsx
+++ b/packages/ui/src/components/TextLink/index.tsx
@@ -1,18 +1,25 @@
+import { I18nKey } from '@logto/phrases';
import classNames from 'classnames';
-import React, { ReactChild } from 'react';
+import React, { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
import * as styles from './index.module.scss';
export type Props = {
className?: string;
- children: ReactChild;
+ children?: ReactNode;
+ text?: I18nKey;
href: string;
+ type?: 'primary' | 'secondary';
+ onClick?: React.MouseEventHandler;
};
-const TextLink = ({ className, children, href }: Props) => {
+const TextLink = ({ className, children, text, href, type = 'primary', onClick }: Props) => {
+ const { t } = useTranslation();
+
return (
-
- {children}
+
+ {children ?? (text ? t(text) : '')}
);
};
diff --git a/packages/ui/src/pages/Register/index.module.scss b/packages/ui/src/pages/Register/index.module.scss
index 661702d4a..f46f49b5e 100644
--- a/packages/ui/src/pages/Register/index.module.scss
+++ b/packages/ui/src/pages/Register/index.module.scss
@@ -16,8 +16,7 @@
}
.title {
- font: var(--font-heading-1);
- color: var(--color-heading);
+ @include _.title;
margin-bottom: _.unit(9);
}
diff --git a/packages/ui/src/pages/Register/index.tsx b/packages/ui/src/pages/Register/index.tsx
index c8a1f9a63..a77757458 100644
--- a/packages/ui/src/pages/Register/index.tsx
+++ b/packages/ui/src/pages/Register/index.tsx
@@ -1,20 +1,19 @@
-import { LogtoErrorI18nKey } from '@logto/phrases';
import classNames from 'classnames';
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { register } from '@/apis/register';
import Button from '@/components/Button';
+import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input';
import PasswordInput from '@/components/Input/PasswordInput';
-import MessageBox from '@/components/MessageBox';
import TextLink from '@/components/TextLink';
import useApi from '@/hooks/use-api';
import * as styles from './index.module.scss';
const Register: FC = () => {
- const { t, i18n } = useTranslation();
+ const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -52,11 +51,7 @@ const Register: FC = () => {
value={password}
onChange={setPassword} // TODO: password validation
/>
- {error && (
-
- {i18n.t(`errors:${error.code}`)}
-
- )}
+ {error && }
diff --git a/packages/ui/src/pages/SignIn/index.module.scss b/packages/ui/src/pages/SignIn/index.module.scss
index d93f73ae9..4a88e0a00 100644
--- a/packages/ui/src/pages/SignIn/index.module.scss
+++ b/packages/ui/src/pages/SignIn/index.module.scss
@@ -16,8 +16,7 @@
}
.title {
- font: var(--font-heading-1);
- color: var(--color-heading);
+ @include _.title;
margin-bottom: _.unit(9);
}
diff --git a/packages/ui/src/pages/SignIn/index.tsx b/packages/ui/src/pages/SignIn/index.tsx
index 8fb2a5d39..72bfb5c08 100644
--- a/packages/ui/src/pages/SignIn/index.tsx
+++ b/packages/ui/src/pages/SignIn/index.tsx
@@ -1,13 +1,12 @@
-import { LogtoErrorI18nKey } from '@logto/phrases';
import classNames from 'classnames';
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { signInBasic } from '@/apis/sign-in';
import Button from '@/components/Button';
+import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input';
import PasswordInput from '@/components/Input/PasswordInput';
-import MessageBox from '@/components/MessageBox';
import TextLink from '@/components/TextLink';
import useApi from '@/hooks/use-api';
@@ -15,7 +14,7 @@ import * as styles from './index.module.scss';
const SignIn: FC = () => {
// TODO: Consider creating cross page data modal
- const { t, i18n } = useTranslation();
+ const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -57,17 +56,15 @@ const SignIn: FC = () => {
className={styles.inputField}
onChange={setPassword}
/>
- {error && (
-
- {i18n.t(`errors:${error.code}`)}
-
- )}
+ {error && }
-
- {t('register.create_account')}
-
+
);
diff --git a/packages/ui/src/scss/_underscore.scss b/packages/ui/src/scss/_underscore.scss
index d84efcb45..1e3340c8c 100644
--- a/packages/ui/src/scss/_underscore.scss
+++ b/packages/ui/src/scss/_underscore.scss
@@ -20,6 +20,16 @@
object-position: center;
}
+@mixin text-hint {
+ font: var(--font-body-small);
+ color: var(--color-font-link-secondary);
+}
+
+@mixin title {
+ font: var(--font-title);
+ color: var(--color-font-primary);
+}
+
@function border($color: transparent, $width: 1) {
@return #{$width}px solid #{$color};
}