Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/heavy-rabbits-own.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions packages/experience/src/__mocks__/logto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -138,7 +138,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
},
customCss: null,
customContent: {},
agreeToTermsPolicy: AgreeToTermsPolicy.Automatic,
agreeToTermsPolicy: mockSignInExperience.agreeToTermsPolicy,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,
Expand Down
18 changes: 16 additions & 2 deletions packages/experience/src/containers/SocialSignInList/use-social.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,6 +18,7 @@ const useSocial = () => {

const handleError = useErrorHandler();
const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl);
const { termsValidation, agreeToTermsPolicy } = useTerms();

const nativeSignInHandler = useCallback(
(redirectTo: string, connector: ExperienceSocialConnector) => {
Expand All @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 25 additions & 2 deletions packages/experience/src/containers/TermsAndPrivacyLinks/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -7,15 +11,34 @@ 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;
}

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>
);
};
Expand Down
11 changes: 7 additions & 4 deletions packages/experience/src/hooks/use-terms.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AgreeToTermsPolicy } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useCallback, useContext, useMemo } from 'react';

Expand All @@ -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]);

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/experience/src/pages/Register/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 43 additions & 10 deletions packages/experience/src/pages/Register/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,21 +11,36 @@ 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';

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;
Expand All @@ -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} />
)
}
</>
)
}
{
Expand All @@ -65,6 +92,7 @@ const RegisterFooter = () => {

const Register = () => {
const { signUpMethods, socialConnectors, signInMode } = useSieMethods();
const { agreeToTermsPolicy } = useTerms();

if (!signInMode) {
return <ErrorPage />;
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@
margin-left: _.unit(0.5);
margin-top: _.unit(-3);
}

.hidden {
display: none;
}
}
Loading
Loading