0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(experience): add identifier sso-only landing page (#6440)

This commit is contained in:
Xiao Yijun 2024-08-16 14:17:17 +08:00 committed by GitHub
parent 26b976a828
commit 737204e54f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 172 additions and 93 deletions

View file

@ -31,6 +31,7 @@ import SignIn from './pages/SignIn';
import SignInPassword from './pages/SignInPassword';
import SingleSignOnConnectors from './pages/SingleSignOnConnectors';
import SingleSignOnEmail from './pages/SingleSignOnEmail';
import SingleSignOnLanding from './pages/SingleSignOnLanding';
import SocialLanding from './pages/SocialLanding';
import SocialLinkAccount from './pages/SocialLinkAccount';
import SocialSignInWebCallback from './pages/SocialSignInWebCallback';
@ -115,7 +116,9 @@ const App = () => {
</Route>
{/* Single sign-on */}
<Route path={experience.routes.sso} element={<LoadingLayerProvider />}>
<Route path={experience.routes.sso}>
{/* Single sign-on first screen landing page */}
{isDevFeaturesEnabled && <Route index element={<SingleSignOnLanding />} />}
<Route path="email" element={<SingleSignOnEmail />} />
<Route path="connectors" element={<SingleSignOnConnectors />} />
</Route>

View file

@ -22,7 +22,13 @@ type Props = {
readonly authOptionsLink: TextLinkProps;
};
const IdentifierPageLayout = ({
/**
* FocusedAuthPageLayout Component
*
* This layout component is designed for focused authentication pages that serve as the first screen
* for specific auth methods, such as identifier sign-in, identifier-register, and single sign-on landing pages.
*/
const FocusedAuthPageLayout = ({
children,
pageMeta,
title,
@ -52,4 +58,4 @@ const IdentifierPageLayout = ({
);
};
export default IdentifierPageLayout;
export default FocusedAuthPageLayout;

View file

@ -2,7 +2,7 @@ import { AgreeToTermsPolicy, experience } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import IdentifierPageLayout from '@/Layout/IdentifierPageLayout';
import FocusedAuthPageLayout from '@/Layout/FocusedAuthPageLayout';
import { identifierInputDescriptionMap } from '@/utils/form';
import IdentifierRegisterForm from '../Register/IdentifierRegisterForm';
@ -21,7 +21,7 @@ const IdentifierRegister = () => {
}
return (
<IdentifierPageLayout
<FocusedAuthPageLayout
pageMeta={{ titleKey: 'description.create_your_account' }}
title="description.create_account"
description={t('description.identifier_register_description', {
@ -34,7 +34,7 @@ const IdentifierRegister = () => {
}}
>
<IdentifierRegisterForm signUpMethods={signUpMethods} />
</IdentifierPageLayout>
</FocusedAuthPageLayout>
);
};

View file

@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import IdentifierPageLayout from '@/Layout/IdentifierPageLayout';
import FocusedAuthPageLayout from '@/Layout/FocusedAuthPageLayout';
import { identifierInputDescriptionMap } from '@/utils/form';
import IdentifierSignInForm from '../SignIn/IdentifierSignInForm';
@ -29,7 +29,7 @@ const IdentifierSignIn = () => {
}
return (
<IdentifierPageLayout
<FocusedAuthPageLayout
pageMeta={{ titleKey: 'description.sign_in' }}
title="description.sign_in"
description={t('description.identifier_sign_in_description', {
@ -49,7 +49,7 @@ const IdentifierSignIn = () => {
) : (
<IdentifierSignInForm signInMethods={signInMethods} />
)}
</IdentifierPageLayout>
</FocusedAuthPageLayout>
);
};

View file

@ -16,4 +16,8 @@
margin-left: _.unit(0.5);
margin-top: _.unit(-3);
}
.terms {
margin-bottom: _.unit(4);
}
}

View file

@ -0,0 +1,116 @@
import { AgreeToTermsPolicy, SignInIdentifier } from '@logto/schemas';
import { useCallback, useContext, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import LockIcon from '@/assets/icons/lock.svg?react';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import SmartInputField, {
type IdentifierInputValue,
} from '@/components/InputFields/SmartInputField';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
import useOnSubmit from '@/hooks/use-check-single-sign-on';
import useTerms from '@/hooks/use-terms';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import styles from './index.module.scss';
type FormState = {
identifier: IdentifierInputValue;
};
type Props = {
readonly isTermsAndPrivacyCheckboxVisible?: boolean;
};
const SingleSignOnForm = ({ isTermsAndPrivacyCheckboxVisible }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
const { ssoEmail } = useContext(UserInteractionContext);
const { termsValidation, agreeToTermsPolicy } = useTerms();
const {
handleSubmit,
control,
formState: { errors, isValid, isSubmitting },
} = useForm<FormState>({
reValidateMode: 'onBlur',
});
useEffect(() => {
if (!isValid) {
clearErrorMessage();
}
}, [clearErrorMessage, isValid]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
/**
* Prevent the default form submission behavior to avoid page reload.
*/
event?.preventDefault();
/**
* Check if the user has agreed to the terms and privacy policy when the policy is set to `Manual`.
*/
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
return;
}
clearErrorMessage();
await handleSubmit(async ({ identifier: { value } }) => onSubmit(value, true))(event);
},
[agreeToTermsPolicy, clearErrorMessage, handleSubmit, onSubmit, termsValidation]
);
return (
<form className={styles.form} onSubmit={onSubmitHandler}>
<Controller
control={control}
name="identifier"
rules={{
validate: ({ value }) => {
if (!value) {
return getGeneralIdentifierErrorMessage([SignInIdentifier.Email], 'required');
}
const errorMessage = validateIdentifierField(SignInIdentifier.Email, value);
return errorMessage
? getGeneralIdentifierErrorMessage([SignInIdentifier.Email], 'invalid')
: true;
},
}}
render={({ field }) => (
<SmartInputField
autoFocus
className={styles.inputField}
{...field}
isDanger={!!errors.identifier}
defaultValue={ssoEmail}
errorMessage={errors.identifier?.message}
enabledTypes={[SignInIdentifier.Email]}
/>
)}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{Boolean(isTermsAndPrivacyCheckboxVisible) && (
<TermsAndPrivacyCheckbox className={styles.terms} />
)}
<Button
title="action.single_sign_on"
htmlType="submit"
icon={<LockIcon />}
isLoading={isSubmitting}
/>
<input hidden type="submit" />
</form>
);
};
export default SingleSignOnForm;

View file

@ -1,96 +1,14 @@
import { SignInIdentifier } from '@logto/schemas';
import { useCallback, useContext, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import LockIcon from '@/assets/icons/lock.svg?react';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import SmartInputField, {
type IdentifierInputValue,
} from '@/components/InputFields/SmartInputField';
import useOnSubmit from '@/hooks/use-check-single-sign-on';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import styles from './index.module.scss';
type FormState = {
identifier: IdentifierInputValue;
};
import SingleSignOnForm from './SingleSignOnForm';
const SingleSignOnEmail = () => {
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
const { ssoEmail } = useContext(UserInteractionContext);
const {
handleSubmit,
control,
formState: { errors, isValid, isSubmitting },
} = useForm<FormState>({
reValidateMode: 'onBlur',
});
useEffect(() => {
if (!isValid) {
clearErrorMessage();
}
}, [clearErrorMessage, isValid]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();
await handleSubmit(async ({ identifier: { value } }) => onSubmit(value, true))(event);
},
[clearErrorMessage, handleSubmit, onSubmit]
);
return (
<SecondaryPageLayout
title="action.single_sign_on"
description="description.single_sign_on_email_form"
>
<form className={styles.form} onSubmit={onSubmitHandler}>
<Controller
control={control}
name="identifier"
rules={{
validate: ({ value }) => {
if (!value) {
return getGeneralIdentifierErrorMessage([SignInIdentifier.Email], 'required');
}
const errorMessage = validateIdentifierField(SignInIdentifier.Email, value);
return errorMessage
? getGeneralIdentifierErrorMessage([SignInIdentifier.Email], 'invalid')
: true;
},
}}
render={({ field }) => (
<SmartInputField
autoFocus
className={styles.inputField}
{...field}
isDanger={!!errors.identifier}
defaultValue={ssoEmail}
errorMessage={errors.identifier?.message}
enabledTypes={[SignInIdentifier.Email]}
/>
)}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<Button
title="action.single_sign_on"
htmlType="submit"
icon={<LockIcon />}
isLoading={isSubmitting}
/>
<input hidden type="submit" />
</form>
<SingleSignOnForm />
</SecondaryPageLayout>
);
};

View file

@ -0,0 +1,32 @@
import { AgreeToTermsPolicy, experience } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import FocusedAuthPageLayout from '@/Layout/FocusedAuthPageLayout';
import useTerms from '@/hooks/use-terms';
import SingleSignOnForm from '../SingleSignOnEmail/SingleSignOnForm';
const SingleSignOnLanding = () => {
const { t } = useTranslation();
const { agreeToTermsPolicy } = useTerms();
return (
<FocusedAuthPageLayout
pageMeta={{ titleKey: 'action.single_sign_on' }}
title="action.single_sign_on"
description={t('description.single_sign_on_email_form')}
footerTermsDisplayPolicies={[AgreeToTermsPolicy.Automatic]}
authOptionsLink={{
to: `/${experience.routes.signIn}`,
text: 'description.all_sign_in_options',
}}
>
<SingleSignOnForm
/* Should display terms and privacy checkbox when we need to confirm the terms and privacy policy for both sign-in and sign-up */
isTermsAndPrivacyCheckboxVisible={agreeToTermsPolicy === AgreeToTermsPolicy.Manual}
/>
</FocusedAuthPageLayout>
);
};
export default SingleSignOnLanding;