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:
parent
3cb7be21ca
commit
061a30a875
36 changed files with 310 additions and 54 deletions
15
.changeset/heavy-rabbits-own.md
Normal file
15
.changeset/heavy-rabbits-own.md
Normal 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 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
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -21,4 +21,8 @@
|
|||
margin-left: _.unit(0.5);
|
||||
margin-top: _.unit(-3);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -29,6 +29,10 @@
|
|||
margin-left: _.unit(0.5);
|
||||
margin-top: _.unit(-3);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.desktop) {
|
||||
|
|
|
@ -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'}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -78,6 +78,7 @@ const description = {
|
|||
not_you: '不是你本人吗?',
|
||||
user_id: '用户 ID: {{id}}',
|
||||
redirect_to: '你将被重定向到 {{name}}。',
|
||||
auto_agreement: '继续即表示您同意<link></link>。',
|
||||
};
|
||||
|
||||
export default Object.freeze(description);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Reference in a new issue