diff --git a/.changeset/heavy-rabbits-own.md b/.changeset/heavy-rabbits-own.md
new file mode 100644
index 000000000..05c70a5e6
--- /dev/null
+++ b/.changeset/heavy-rabbits-own.md
@@ -0,0 +1,15 @@
+---
+"@logto/phrases-experience": minor
+"@logto/integration-tests": minor
+"@logto/experience": minor
+"@logto/console": minor
+"@logto/phrases": minor
+"@logto/schemas": minor
+"@logto/core": minor
+---
+
+support agree to terms polices for Logto’s sign-in experiences
+
+- Automatic: Users automatically agree to terms by continuing to use the service
+- ManualRegistrationOnly: Users must agree to terms by checking a box during registration, and don't need to agree when signing in
+- Manual: Users must agree to terms by checking a box during registration or signing in
diff --git a/packages/experience/src/__mocks__/logto.tsx b/packages/experience/src/__mocks__/logto.tsx
index fff0e1daf..6aa40a6eb 100644
--- a/packages/experience/src/__mocks__/logto.tsx
+++ b/packages/experience/src/__mocks__/logto.tsx
@@ -105,7 +105,7 @@ export const mockSignInExperience: SignInExperience = {
signInMode: SignInMode.SignInAndRegister,
customCss: null,
customContent: {},
- agreeToTermsPolicy: AgreeToTermsPolicy.Automatic,
+ agreeToTermsPolicy: AgreeToTermsPolicy.ManualRegistrationOnly,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,
@@ -138,7 +138,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
},
customCss: null,
customContent: {},
- agreeToTermsPolicy: AgreeToTermsPolicy.Automatic,
+ agreeToTermsPolicy: mockSignInExperience.agreeToTermsPolicy,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,
diff --git a/packages/experience/src/containers/SocialSignInList/use-social.ts b/packages/experience/src/containers/SocialSignInList/use-social.ts
index 74c4fffc5..6e884c55d 100644
--- a/packages/experience/src/containers/SocialSignInList/use-social.ts
+++ b/packages/experience/src/containers/SocialSignInList/use-social.ts
@@ -1,10 +1,15 @@
-import { ConnectorPlatform, type ExperienceSocialConnector } from '@logto/schemas';
+import {
+ AgreeToTermsPolicy,
+ ConnectorPlatform,
+ type ExperienceSocialConnector,
+} from '@logto/schemas';
import { useCallback, useContext } from 'react';
import PageContext from '@/Providers/PageContextProvider/PageContext';
import { getSocialAuthorizationUrl } from '@/apis/interaction';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
+import useTerms from '@/hooks/use-terms';
import { getLogtoNativeSdk, isNativeWebview } from '@/utils/native-sdk';
import { generateState, storeState, buildSocialLandingUri } from '@/utils/social-connectors';
@@ -13,6 +18,7 @@ const useSocial = () => {
const handleError = useErrorHandler();
const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl);
+ const { termsValidation, agreeToTermsPolicy } = useTerms();
const nativeSignInHandler = useCallback(
(redirectTo: string, connector: ExperienceSocialConnector) => {
@@ -33,6 +39,14 @@ const useSocial = () => {
const invokeSocialSignInHandler = useCallback(
async (connector: ExperienceSocialConnector) => {
+ /**
+ * Check if the user has agreed to the terms and privacy policy before navigating to the 3rd-party social sign-in page
+ * when the policy is set to `Manual`
+ */
+ if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
+ return;
+ }
+
const { id: connectorId } = connector;
const state = generateState();
@@ -64,7 +78,7 @@ const useSocial = () => {
// Invoke web social sign-in flow
window.location.assign(result.redirectTo);
},
- [asyncInvokeSocialSignIn, handleError, nativeSignInHandler]
+ [agreeToTermsPolicy, asyncInvokeSocialSignIn, handleError, nativeSignInHandler, termsValidation]
);
return {
diff --git a/packages/experience/src/containers/TermsAndPrivacyLinks/index.tsx b/packages/experience/src/containers/TermsAndPrivacyLinks/index.tsx
index dfe093e9e..3778b4485 100644
--- a/packages/experience/src/containers/TermsAndPrivacyLinks/index.tsx
+++ b/packages/experience/src/containers/TermsAndPrivacyLinks/index.tsx
@@ -1,3 +1,7 @@
+import { AgreeToTermsPolicy } from '@logto/schemas';
+import { t } from 'i18next';
+import { Trans } from 'react-i18next';
+
import TermsLinks from '@/components/TermsLinks';
import useTerms from '@/hooks/use-terms';
@@ -7,7 +11,7 @@ type Props = {
// For sign-in page displaying terms and privacy links use only. No user interaction is needed.
const TermsAndPrivacyLinks = ({ className }: Props) => {
- const { termsOfUseUrl, privacyPolicyUrl, isTermsDisabled } = useTerms();
+ const { termsOfUseUrl, privacyPolicyUrl, isTermsDisabled, agreeToTermsPolicy } = useTerms();
if (isTermsDisabled) {
return null;
@@ -15,7 +19,26 @@ const TermsAndPrivacyLinks = ({ className }: Props) => {
return (
-
+ {
+ // Display the automatic agreement message when the policy is set to `Automatic`
+ agreeToTermsPolicy === AgreeToTermsPolicy.Automatic ? (
+
+ ),
+ }}
+ >
+ {t('description.auto_agreement')}
+
+ ) : (
+
+ )
+ }
);
};
diff --git a/packages/experience/src/hooks/use-terms.ts b/packages/experience/src/hooks/use-terms.ts
index ca873a600..dd95c03ae 100644
--- a/packages/experience/src/hooks/use-terms.ts
+++ b/packages/experience/src/hooks/use-terms.ts
@@ -1,3 +1,4 @@
+import { AgreeToTermsPolicy } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useCallback, useContext, useMemo } from 'react';
@@ -10,14 +11,15 @@ const useTerms = () => {
const { termsAgreement, setTermsAgreement, experienceSettings } = useContext(PageContext);
const { show } = useConfirmModal();
- const { termsOfUseUrl, privacyPolicyUrl, isTermsDisabled } = useMemo(() => {
- const { termsOfUseUrl, privacyPolicyUrl } = experienceSettings ?? {};
+ const { termsOfUseUrl, privacyPolicyUrl, isTermsDisabled, agreeToTermsPolicy } = useMemo(() => {
+ const { termsOfUseUrl, privacyPolicyUrl, agreeToTermsPolicy } = experienceSettings ?? {};
const isTermsDisabled = !termsOfUseUrl && !privacyPolicyUrl;
return {
termsOfUseUrl: conditional(termsOfUseUrl),
privacyPolicyUrl: conditional(privacyPolicyUrl),
isTermsDisabled,
+ agreeToTermsPolicy,
};
}, [experienceSettings]);
@@ -36,18 +38,19 @@ const useTerms = () => {
}, [setTermsAgreement, show]);
const termsValidation = useCallback(async () => {
- if (termsAgreement || isTermsDisabled) {
+ if (termsAgreement || isTermsDisabled || agreeToTermsPolicy === AgreeToTermsPolicy.Automatic) {
return true;
}
return termsAndPrivacyConfirmModalHandler();
- }, [termsAgreement, isTermsDisabled, termsAndPrivacyConfirmModalHandler]);
+ }, [termsAgreement, isTermsDisabled, agreeToTermsPolicy, termsAndPrivacyConfirmModalHandler]);
return {
termsOfUseUrl,
privacyPolicyUrl,
termsAgreement,
isTermsDisabled,
+ agreeToTermsPolicy,
termsValidation,
setTermsAgreement,
termsAndPrivacyConfirmModalHandler,
diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx
index 96bfb20e4..af0dc5734 100644
--- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx
+++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx
@@ -1,4 +1,4 @@
-import type { SignInIdentifier } from '@logto/schemas';
+import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
@@ -30,7 +30,7 @@ type FormState = {
const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) => {
const { t } = useTranslation();
- const { termsValidation } = useTerms();
+ const { termsValidation, agreeToTermsPolicy } = useTerms();
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
@@ -131,7 +131,15 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
* If the autofill value is SSO enabled, it will always show SSO form.
*/}