0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(experience): support agree to terms policies (#6044)

This commit is contained in:
Xiao Yijun 2024-06-19 09:25:57 +08:00 committed by GitHub
parent 3cb7be21ca
commit 061a30a875
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 310 additions and 54 deletions

View file

@ -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 Logtos 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

View file

@ -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,

View file

@ -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 {

View file

@ -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 (
<div className={className}>
<TermsLinks termsOfUseUrl={termsOfUseUrl} privacyPolicyUrl={privacyPolicyUrl} />
{
// Display the automatic agreement message when the policy is set to `Automatic`
agreeToTermsPolicy === AgreeToTermsPolicy.Automatic ? (
<Trans
components={{
link: (
<TermsLinks
inline
termsOfUseUrl={termsOfUseUrl}
privacyPolicyUrl={privacyPolicyUrl}
/>
),
}}
>
{t('description.auto_agreement')}
</Trans>
) : (
<TermsLinks termsOfUseUrl={termsOfUseUrl} privacyPolicyUrl={privacyPolicyUrl} />
)
}
</div>
);
};

View file

@ -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,

View file

@ -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.
*/}
<TermsAndPrivacyCheckbox
className={classNames(styles.terms, showSingleSignOnForm && styles.hidden)}
className={classNames(
styles.terms,
/**
* Hide the terms checkbox when the policy is set to `Automatic`.
* In registration, the terms checkbox is always shown for `Manual` and `ManualRegistrationOnly` policies.
*/
(showSingleSignOnForm || agreeToTermsPolicy === AgreeToTermsPolicy.Automatic) &&
styles.hidden
)}
/>
<Button

View file

@ -8,6 +8,13 @@
.terms {
margin-bottom: _.unit(4);
text-align: center;
@include _.text-hint;
font: var(--font-body-3);
}
.checkbox {
justify-content: center;
}
.createAccount,

View file

@ -1,7 +1,7 @@
import { SignInMode } from '@logto/schemas';
import { useContext } from 'react';
import { AgreeToTermsPolicy, SignInMode } from '@logto/schemas';
import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import { Navigate, useNavigate } from 'react-router-dom';
import LandingPageLayout from '@/Layout/LandingPageLayout';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
@ -11,7 +11,9 @@ import GoogleOneTap from '@/components/GoogleOneTap';
import TextLink from '@/components/TextLink';
import SocialSignInList from '@/containers/SocialSignInList';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
import TermsAndPrivacyLinks from '@/containers/TermsAndPrivacyLinks';
import { useSieMethods } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
import ErrorPage from '../ErrorPage';
@ -19,13 +21,26 @@ import IdentifierRegisterForm from './IdentifierRegisterForm';
import * as styles from './index.module.scss';
const RegisterFooter = () => {
const { t } = useTranslation();
const { signUpMethods, socialConnectors, signInMode, signInMethods, singleSignOnEnabled } =
useSieMethods();
const { t } = useTranslation();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const navigate = useNavigate();
const { showSingleSignOnForm } = useContext(SingleSignOnFormModeContext);
const handleSsoNavigation = useCallback(async () => {
/**
* Check if the user has agreed to the terms and privacy policy before navigating to the SSO page
* when the policy is set to `Manual`
*/
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
return;
}
navigate('/single-sign-on/email');
}, [agreeToTermsPolicy, navigate, termsValidation]);
/* Hide footers when showing Single Sign On form */
if (showSingleSignOnForm) {
return null;
@ -36,10 +51,22 @@ const RegisterFooter = () => {
{
// Single Sign On footer
singleSignOnEnabled && (
<div className={styles.singleSignOn}>
{t('description.use')}{' '}
<TextLink to="/single-sign-on/email" text="action.single_sign_on" />
</div>
<>
<div className={styles.singleSignOn}>
{t('description.use')}{' '}
<TextLink text="action.single_sign_on" onClick={handleSsoNavigation} />
</div>
{
/**
* If only SSO sign-in methods are available, display the agreement checkbox when the agreement policy is `Manual`.
*/
signInMethods.length === 0 &&
socialConnectors.length === 0 &&
agreeToTermsPolicy === AgreeToTermsPolicy.Manual && (
<TermsAndPrivacyCheckbox className={styles.checkbox} />
)
}
</>
)
}
{
@ -65,6 +92,7 @@ const RegisterFooter = () => {
const Register = () => {
const { signUpMethods, socialConnectors, signInMode } = useSieMethods();
const { agreeToTermsPolicy } = useTerms();
if (!signInMode) {
return <ErrorPage />;
@ -84,11 +112,16 @@ const Register = () => {
{/* Social sign-in methods only */}
{signUpMethods.length === 0 && socialConnectors.length > 0 && (
<>
<TermsAndPrivacyCheckbox className={styles.terms} />
{agreeToTermsPolicy !== AgreeToTermsPolicy.Automatic && (
<TermsAndPrivacyCheckbox className={styles.terms} />
)}
<SocialSignInList className={styles.main} socialConnectors={socialConnectors} />
</>
)}
<RegisterFooter />
{agreeToTermsPolicy === AgreeToTermsPolicy.Automatic && (
<TermsAndPrivacyLinks className={styles.terms} />
)}
</SingleSignOnFormModeContextProvider>
{/* Hide footer elements when showing Single Sign On form */}
</LandingPageLayout>

View file

@ -21,4 +21,8 @@
margin-left: _.unit(0.5);
margin-top: _.unit(-3);
}
.hidden {
display: none;
}
}

View file

@ -1,4 +1,4 @@
import type { SignIn } from '@logto/schemas';
import { AgreeToTermsPolicy, type SignIn } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback, useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
@ -9,7 +9,9 @@ import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import { SmartInputField } from '@/components/InputFields';
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
import useSingleSignOnWatch from '@/hooks/use-single-sign-on-watch';
import useTerms from '@/hooks/use-terms';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
@ -29,6 +31,7 @@ type FormState = {
const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { t } = useTranslation();
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(signInMethods);
const { termsValidation, agreeToTermsPolicy } = useTerms();
const enabledSignInMethods = useMemo(
() => signInMethods.map(({ identifier }) => identifier),
@ -69,10 +72,23 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
return;
}
// Check if the user has agreed to the terms and privacy policy before signing in when the policy is set to `Manual`
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
return;
}
await onSubmit(type, value);
})(event);
},
[clearErrorMessage, handleSubmit, navigateToSingleSignOn, onSubmit, showSingleSignOnForm]
[
agreeToTermsPolicy,
clearErrorMessage,
handleSubmit,
navigateToSingleSignOn,
onSubmit,
showSingleSignOnForm,
termsValidation,
]
);
return (
@ -111,6 +127,20 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
<div className={styles.message}>{t('description.single_sign_on_enabled')}</div>
)}
{/**
* Have to use css to hide the terms element.
* Remove element from dom will trigger a form re-render.
* Form rerender will trigger autofill.
* If the autofill value is SSO enabled, it will always show SSO form.
*/}
<TermsAndPrivacyCheckbox
className={classNames(
// For sign in, only show the terms checkbox if the terms policy is manual
(showSingleSignOnForm || agreeToTermsPolicy !== AgreeToTermsPolicy.Manual) &&
styles.hidden
)}
/>
<Button
name="submit"
title={showSingleSignOnForm ? 'action.single_sign_on' : 'action.sign_in'}

View file

@ -1,6 +1,8 @@
import type { SignIn, ExperienceSocialConnector } from '@logto/schemas';
import { type SignIn, type ExperienceSocialConnector, AgreeToTermsPolicy } from '@logto/schemas';
import SocialSignInList from '@/containers/SocialSignInList';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
import useTerms from '@/hooks/use-terms';
import IdentifierSignInForm from './IdentifierSignInForm';
import PasswordSignInForm from './PasswordSignInForm';
@ -12,8 +14,23 @@ type Props = {
};
const Main = ({ signInMethods, socialConnectors }: Props) => {
const { agreeToTermsPolicy } = useTerms();
if (signInMethods.length === 0 && socialConnectors.length > 0) {
return <SocialSignInList className={styles.main} socialConnectors={socialConnectors} />;
return (
<>
<SocialSignInList className={styles.main} socialConnectors={socialConnectors} />
{
/**
* Display agreement checkbox when only social sign-in methods are available
* and the user needs to agree to terms manually.
*/
agreeToTermsPolicy === AgreeToTermsPolicy.Manual && (
<TermsAndPrivacyCheckbox className={styles.checkbox} />
)
}
</>
);
}
const isPasswordOnly =

View file

@ -29,6 +29,10 @@
margin-left: _.unit(0.5);
margin-top: _.unit(-3);
}
.hidden {
display: none;
}
}
:global(.desktop) {

View file

@ -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 { useForm, Controller } from 'react-hook-form';
@ -10,9 +10,11 @@ import ErrorMessage from '@/components/ErrorMessage';
import { SmartInputField, PasswordInputField } from '@/components/InputFields';
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import ForgotPasswordLink from '@/containers/ForgotPasswordLink';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import useSingleSignOnWatch from '@/hooks/use-single-sign-on-watch';
import useTerms from '@/hooks/use-terms';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
@ -34,6 +36,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const {
watch,
@ -67,13 +70,26 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
return;
}
// Check if the user has agreed to the terms and privacy policy before signing in when the policy is set to `Manual`
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
return;
}
await onSubmit({
[type]: value,
password,
});
})(event);
},
[clearErrorMessage, handleSubmit, navigateToSingleSignOn, onSubmit, showSingleSignOnForm]
[
agreeToTermsPolicy,
clearErrorMessage,
handleSubmit,
navigateToSingleSignOn,
onSubmit,
showSingleSignOnForm,
termsValidation,
]
);
useEffect(() => {
@ -134,6 +150,21 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
/>
)}
{/**
* Have to use css to hide the terms element.
* Remove element from dom will trigger a form re-render.
* Form rerender will trigger autofill.
* If the autofill value is SSO enabled, it will always show SSO form.
*/}
<TermsAndPrivacyCheckbox
className={classNames(
styles.terms,
// For sign in, only show the terms checkbox if the terms policy is manual
(showSingleSignOnForm || agreeToTermsPolicy !== AgreeToTermsPolicy.Manual) &&
styles.hidden
)}
/>
<Button
name="submit"
title={showSingleSignOnForm ? 'action.single_sign_on' : 'action.sign_in'}

View file

@ -8,10 +8,18 @@
.terms {
margin-top: _.unit(4);
@include _.text-hint;
text-align: center;
font: var(--font-body-3);
}
.checkbox {
margin-bottom: _.unit(4);
}
.checkboxForSsoOnly {
justify-content: center;
}
.createAccount,
.singleSignOn {
@ -23,7 +31,6 @@
flex: 1;
}
:global(body.desktop) {
.placeHolder {
flex: 0;

View file

@ -50,8 +50,8 @@ describe('<SignIn />', () => {
});
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
expect(queryByText('description.terms_of_use')).not.toBeNull();
expect(queryByText('description.privacy_policy')).not.toBeNull();
expect(queryAllByText('description.terms_of_use')).not.toBeNull();
expect(queryAllByText('description.privacy_policy')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
@ -66,7 +66,7 @@ describe('<SignIn />', () => {
test.each(mockSignInMethodSettingsTestCases)(
'renders with [%p %p %p] SignIn Methods only mode',
async (...methods) => {
const { container, queryByText } = renderSignIn({
const { container, queryAllByAltText } = renderSignIn({
signIn: {
methods,
},
@ -74,8 +74,8 @@ describe('<SignIn />', () => {
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).toBeNull();
expect(queryByText('description.terms_of_use')).not.toBeNull();
expect(queryByText('description.privacy_policy')).not.toBeNull();
expect(queryAllByAltText('description.terms_of_use')).not.toBeNull();
expect(queryAllByAltText('description.privacy_policy')).not.toBeNull();
}
);
});

View file

@ -1,7 +1,7 @@
import { SignInMode } from '@logto/schemas';
import { useContext } from 'react';
import { AgreeToTermsPolicy, SignInMode } from '@logto/schemas';
import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import { Navigate, useNavigate } from 'react-router-dom';
import LandingPageLayout from '@/Layout/LandingPageLayout';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
@ -10,8 +10,10 @@ import Divider from '@/components/Divider';
import GoogleOneTap from '@/components/GoogleOneTap';
import TextLink from '@/components/TextLink';
import SocialSignInList from '@/containers/SocialSignInList';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
import TermsAndPrivacyLinks from '@/containers/TermsAndPrivacyLinks';
import { useSieMethods } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
import ErrorPage from '../ErrorPage';
@ -20,12 +22,25 @@ import * as styles from './index.module.scss';
const SignInFooters = () => {
const { t } = useTranslation();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const navigate = useNavigate();
const { signInMethods, signUpMethods, socialConnectors, signInMode, singleSignOnEnabled } =
useSieMethods();
const { showSingleSignOnForm } = useContext(SingleSignOnFormModeContext);
const handleSsoNavigation = useCallback(async () => {
/**
* Check if the user has agreed to the terms and privacy policy before navigating to the SSO page
* when the policy is set to `Manual`
*/
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
return;
}
navigate('/single-sign-on/email');
}, [agreeToTermsPolicy, navigate, termsValidation]);
/* Hide footers when showing Single Sign On form */
if (showSingleSignOnForm) {
return null;
@ -36,10 +51,22 @@ const SignInFooters = () => {
{
// Single Sign On footer
singleSignOnEnabled && (
<div className={styles.singleSignOn}>
{t('description.use')}{' '}
<TextLink to="/single-sign-on/email" text="action.single_sign_on" />
</div>
<>
<div className={styles.singleSignOn}>
{t('description.use')}{' '}
<TextLink text="action.single_sign_on" onClick={handleSsoNavigation} />
</div>
{
/**
* If only SSO sign-in methods are available, display the agreement checkbox when the agreement policy is `Manual`.
*/
signInMethods.length === 0 &&
socialConnectors.length === 0 &&
agreeToTermsPolicy === AgreeToTermsPolicy.Manual && (
<TermsAndPrivacyCheckbox className={styles.checkboxForSsoOnly} />
)
}
</>
)
}
{
@ -66,6 +93,7 @@ const SignInFooters = () => {
const SignIn = () => {
const { signInMethods, socialConnectors, signInMode } = useSieMethods();
const { agreeToTermsPolicy } = useTerms();
if (!signInMode) {
return <ErrorPage />;
@ -82,7 +110,12 @@ const SignIn = () => {
<Main signInMethods={signInMethods} socialConnectors={socialConnectors} />
<SignInFooters />
</SingleSignOnFormModeContextProvider>
<TermsAndPrivacyLinks className={styles.terms} />
{
// Only show terms and privacy links for sign in page if the agree to terms policy is `Automatic` or `ManualRegistrationOnly`
agreeToTermsPolicy !== AgreeToTermsPolicy.Manual && (
<TermsAndPrivacyLinks className={styles.terms} />
)
}
</LandingPageLayout>
);
};

View file

@ -1,4 +1,4 @@
import { SignInMode, experience } from '@logto/schemas';
import { AgreeToTermsPolicy, SignInMode, experience } from '@logto/schemas';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
@ -16,14 +16,18 @@ import { validateState } from '@/utils/social-connectors';
const useSingleSignOnRegister = () => {
const handleError = useErrorHandler();
const request = useApi(singleSignOnRegistration);
const { termsValidation } = useTerms();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const navigate = useNavigate();
const redirectTo = useGlobalRedirectTo();
return useCallback(
async (connectorId: string) => {
// Agree to terms and conditions first before proceeding
if (!(await termsValidation())) {
/**
* Agree to terms and conditions first before proceeding
* If the agreement policy is `Manual`, the user must agree to the terms to reach this step.
* Therefore, skip the check for `Manual` policy.
*/
if (agreeToTermsPolicy !== AgreeToTermsPolicy.Manual && !(await termsValidation())) {
navigate('/' + experience.routes.signIn);
return;
}

View file

@ -1,6 +1,6 @@
import { GoogleConnector } from '@logto/connector-kit';
import type { RequestErrorBody } from '@logto/schemas';
import { InteractionEvent, SignInMode, experience } from '@logto/schemas';
import { AgreeToTermsPolicy, InteractionEvent, SignInMode, experience } from '@logto/schemas';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
@ -25,7 +25,7 @@ const useSocialSignInListener = (connectorId: string) => {
const { setToast } = useToast();
const { signInMode, socialSignInSettings } = useSieMethods();
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const [isConsumed, setIsConsumed] = useState(false);
const [searchParameters, setSearchParameters] = useSearchParams();
@ -82,8 +82,12 @@ const useSocialSignInListener = (connectorId: string) => {
return;
}
// Agree to terms and conditions first before proceeding
if (!(await termsValidation())) {
/**
* Agree to terms and conditions first before proceeding
* If the agreement policy is `Manual`, the user must agree to the terms to reach this step.
* Therefore, skip the check for `Manual` policy.
*/
if (agreeToTermsPolicy !== AgreeToTermsPolicy.Manual && !(await termsValidation())) {
navigate('/' + experience.routes.signIn);
return;
}
@ -100,6 +104,7 @@ const useSocialSignInListener = (connectorId: string) => {
[
preSignInErrorHandler,
signInMode,
agreeToTermsPolicy,
termsValidation,
accountNotExistErrorHandler,
setToast,

View file

@ -1,5 +1,5 @@
import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier } from '@logto/schemas';
import { AgreeToTermsPolicy, SignInIdentifier } from '@logto/schemas';
import { createUser, deleteUser } from '#src/api/admin-user.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
@ -59,6 +59,7 @@ describe('automatic account linking', () => {
await updateSignInExperience({
termsOfUseUrl: 'https://example.com/terms',
privacyPolicyUrl: 'https://example.com/privacy',
agreeToTermsPolicy: AgreeToTermsPolicy.ManualRegistrationOnly,
socialSignIn: { automaticAccountLinking: true },
});
const socialUserId = 'foo_' + randomString();

View file

@ -1,5 +1,5 @@
import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier, SsoProviderName } from '@logto/schemas';
import { AgreeToTermsPolicy, SignInIdentifier, SsoProviderName } from '@logto/schemas';
import { appendPath } from '@silverhand/essentials';
import { mockSocialConnectorTarget } from '#src/__mocks__/connectors-mock.js';
@ -40,6 +40,7 @@ describe('direct sign-in', () => {
await updateSignInExperience({
termsOfUseUrl: 'https://example.com/terms',
privacyPolicyUrl: 'https://example.com/privacy',
agreeToTermsPolicy: AgreeToTermsPolicy.ManualRegistrationOnly,
signUp: { identifiers: [], password: true, verify: false },
signIn: {
methods: [

View file

@ -1,7 +1,7 @@
import crypto from 'node:crypto';
import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier, SignInMode, SsoProviderName } from '@logto/schemas';
import { AgreeToTermsPolicy, SignInIdentifier, SignInMode, SsoProviderName } from '@logto/schemas';
import { appendPath } from '@silverhand/essentials';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
@ -53,6 +53,7 @@ describe('social sign-in (with email identifier)', () => {
await updateSignInExperience({
termsOfUseUrl: 'https://example.com/terms',
privacyPolicyUrl: 'https://example.com/privacy',
agreeToTermsPolicy: AgreeToTermsPolicy.ManualRegistrationOnly,
signUp: { identifiers: [SignInIdentifier.Email], password: true, verify: true },
signIn: {
methods: [

View file

@ -100,6 +100,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Indem Sie fortfahren, stimmen Sie den <link></link> zu.',
};
export default Object.freeze(description);

View file

@ -85,6 +85,7 @@ const description = {
not_you: 'Not you?',
user_id: 'User ID: {{id}}',
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'By continuing, you agree to the <link></link>.',
};
export default Object.freeze(description);

View file

@ -99,6 +99,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Al continuar, acepta los <link></link>.',
};
export default Object.freeze(description);

View file

@ -100,6 +100,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'En continuant, vous acceptez les <link></link>.',
};
export default Object.freeze(description);

View file

@ -96,6 +96,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Continuando, accetti i <link></link>.',
};
export default Object.freeze(description);

View file

@ -96,6 +96,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: '続行することで、<link></link>に同意したことになります。',
};
export default Object.freeze(description);

View file

@ -91,6 +91,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: '계속 진행하면 <link></link>에 동의하는 것입니다.',
};
export default Object.freeze(description);

View file

@ -96,6 +96,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Kontynuując, zgadzasz się na <link></link>.',
};
export default Object.freeze(description);

View file

@ -95,6 +95,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Ao continuar, você concorda com os <link></link>.',
};
export default Object.freeze(description);

View file

@ -95,6 +95,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Ao continuar, você concorda com os <link></link>.',
};
export default Object.freeze(description);

View file

@ -99,6 +99,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Продолжая, вы соглашаетесь с <link></link>.',
};
export default Object.freeze(description);

View file

@ -95,6 +95,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Devam ederek <link></link> kabul etmiş oluyorsunuz.',
};
export default Object.freeze(description);

View file

@ -78,6 +78,7 @@ const description = {
not_you: '不是你本人吗?',
user_id: '用户 ID: {{id}}',
redirect_to: '你将被重定向到 {{name}}。',
auto_agreement: '继续即表示您同意<link></link>。',
};
export default Object.freeze(description);

View file

@ -87,6 +87,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: '繼續即表示您同意<link></link>。',
};
export default Object.freeze(description);

View file

@ -87,6 +87,7 @@ const description = {
user_id: 'User ID: {{id}}',
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: '繼續即表示您同意<link></link>。',
};
export default Object.freeze(description);