Skip to content

Commit

Permalink
feat(experience): add identifier sso-only landing page (#6440)
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun authored Aug 16, 2024
1 parent 26b976a commit 737204e
Show file tree
Hide file tree
Showing 9 changed files with 172 additions and 93 deletions.
5 changes: 4 additions & 1 deletion packages/experience/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -52,4 +58,4 @@ const IdentifierPageLayout = ({
);
};

export default IdentifierPageLayout;
export default FocusedAuthPageLayout;
6 changes: 3 additions & 3 deletions packages/experience/src/pages/IdentifierRegister/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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', {
Expand All @@ -34,7 +34,7 @@ const IdentifierRegister = () => {
}}
>
<IdentifierRegisterForm signUpMethods={signUpMethods} />
</IdentifierPageLayout>
</FocusedAuthPageLayout>
);
};

Expand Down
6 changes: 3 additions & 3 deletions packages/experience/src/pages/IdentifierSignIn/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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', {
Expand All @@ -49,7 +49,7 @@ const IdentifierSignIn = () => {
) : (
<IdentifierSignInForm signInMethods={signInMethods} />
)}
</IdentifierPageLayout>
</FocusedAuthPageLayout>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@
margin-left: _.unit(0.5);
margin-top: _.unit(-3);
}

.terms {
margin-bottom: _.unit(4);
}
}
Original file line number Diff line number Diff line change
@@ -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;
86 changes: 2 additions & 84 deletions packages/experience/src/pages/SingleSignOnEmail/index.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Expand Down
32 changes: 32 additions & 0 deletions packages/experience/src/pages/SingleSignOnLanding/index.tsx
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit 737204e

Please sign in to comment.