Skip to content

Commit

Permalink
feat(experience): add identifier sign-in page
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun committed Aug 13, 2024
1 parent ab90f43 commit 9948ae6
Show file tree
Hide file tree
Showing 28 changed files with 337 additions and 32 deletions.
12 changes: 12 additions & 0 deletions packages/experience/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import LoadingLayerProvider from './Providers/LoadingLayerProvider';
import PageContextProvider from './Providers/PageContextProvider';
import SettingsProvider from './Providers/SettingsProvider';
import UserInteractionContextProvider from './Providers/UserInteractionContextProvider';
import { isDevFeaturesEnabled } from './constants/env';
import Callback from './pages/Callback';
import Consent from './pages/Consent';
import Continue from './pages/Continue';
import DirectSignIn from './pages/DirectSignIn';
import ErrorPage from './pages/ErrorPage';
import ForgotPassword from './pages/ForgotPassword';
import IdentifierSignIn from './pages/IdentifierSignIn';
import MfaBinding from './pages/MfaBinding';
import BackupCodeBinding from './pages/MfaBinding/BackupCodeBinding';
import TotpBinding from './pages/MfaBinding/TotpBinding';
Expand Down Expand Up @@ -120,6 +122,16 @@ const App = () => {
{/* Consent */}
<Route path="consent" element={<Consent />} />

{isDevFeaturesEnabled && (
<>
{/* Identifier sign-in */}
<Route
path={experience.routes.identifierSignIn}
element={<IdentifierSignIn />}
/>
</>
)}

<Route path="*" element={<ErrorPage />} />
</Route>
</Route>
Expand Down
25 changes: 25 additions & 0 deletions packages/experience/src/Layout/FirstScreenLayout/index.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@use '@/scss/underscore' as _;

.wrapper {
@include _.full-page;
@include _.flex-column(normal, normal);
@include _.full-width;

> *:last-child {
margin-bottom: 0;
}
}

:global(body.desktop) {
.wrapper {
padding: _.unit(6) 0;
}

.placeholderTop {
flex: 3;
}

.placeholderBottom {
flex: 5;
}
}
28 changes: 28 additions & 0 deletions packages/experience/src/Layout/FirstScreenLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { type ReactNode, useContext } from 'react';

import PageContext from '@/Providers/PageContextProvider/PageContext';

import PageMeta from '../../components/PageMeta';
import type { Props as PageMetaProps } from '../../components/PageMeta';

import styles from './index.module.scss';

type Props = {
readonly children: ReactNode;
readonly pageMeta: PageMetaProps;
};

const FirstScreenLayout = ({ children, pageMeta }: Props) => {
const { platform } = useContext(PageContext);

return (
<>
<PageMeta {...pageMeta} />
{platform === 'web' && <div className={styles.placeholderTop} />}
<div className={styles.wrapper}>{children}</div>
{platform === 'web' && <div className={styles.placeholderBottom} />}
</>
);
};

export default FirstScreenLayout;
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@use '@/scss/underscore' as _;

.header {
margin: _.unit(6) 0;
}

.description {
margin-top: _.unit(2);
@include _.text-hint;
}

.terms {
margin-top: _.unit(4);
@include _.text-hint;
text-align: center;
font: var(--font-body-3);
}

.link {
margin-top: _.unit(7);
}

:global(body.mobile) {
.title {
@include _.title;
}
}

:global(body.desktop) {
.title {
@include _.title_desktop;
}
}
55 changes: 55 additions & 0 deletions packages/experience/src/Layout/IdentifierPageLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { type AgreeToTermsPolicy } from '@logto/schemas';
import { type TFuncKey } from 'i18next';
import { useMemo, type ReactNode } from 'react';

import DynamicT from '@/components/DynamicT';
import type { Props as PageMetaProps } from '@/components/PageMeta';
import type { Props as TextLinkProps } from '@/components/TextLink';
import TextLink from '@/components/TextLink';
import TermsAndPrivacyLinks from '@/containers/TermsAndPrivacyLinks';
import useTerms from '@/hooks/use-terms';

import FirstScreenLayout from '../FirstScreenLayout';

import styles from './index.module.scss';

type Props = {
readonly children: ReactNode;
readonly pageMeta: PageMetaProps;
readonly title: TFuncKey;
readonly description: string;
readonly footerTermsDisplayPolicies: AgreeToTermsPolicy[];
readonly authOptionsLink: TextLinkProps;
};

const IdentifierPageLayout = ({
children,
pageMeta,
title,
description,
footerTermsDisplayPolicies,
authOptionsLink,
}: Props) => {
const { agreeToTermsPolicy } = useTerms();

const shouldDisplayFooterTerms = useMemo(
() => agreeToTermsPolicy && footerTermsDisplayPolicies.includes(agreeToTermsPolicy),
[agreeToTermsPolicy, footerTermsDisplayPolicies]
);

return (
<FirstScreenLayout pageMeta={pageMeta}>
<div className={styles.header}>
<div className={styles.title}>
<DynamicT forKey={title} />
</div>
<div className={styles.description}>{description}</div>
</div>
{children}
{shouldDisplayFooterTerms && <TermsAndPrivacyLinks className={styles.terms} />}
<TextLink {...authOptionsLink} className={styles.link} />
</FirstScreenLayout>
);
};

export default IdentifierPageLayout;
45 changes: 17 additions & 28 deletions packages/experience/src/Layout/LandingPageLayout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,24 @@ import { useContext } from 'react';

import PageContext from '@/Providers/PageContextProvider/PageContext';
import BrandingHeader from '@/components/BrandingHeader';
import PageMeta from '@/components/PageMeta';
import { layoutClassNames } from '@/utils/consts';
import { getBrandingLogoUrl } from '@/utils/logo';

import FirstScreenLayout from '../FirstScreenLayout';

import styles from './index.module.scss';

type ThirdPartyBranding = ConsentInfoResponse['application']['branding'];

type Props = {
readonly children: ReactNode;
readonly className?: string;
readonly title: TFuncKey;
readonly titleInterpolation?: Record<string, unknown>;
readonly thirdPartyBranding?: ThirdPartyBranding;
};

const LandingPageLayout = ({
children,
className,
title,
titleInterpolation,
thirdPartyBranding,
}: Props) => {
const { experienceSettings, theme, platform } = useContext(PageContext);
const LandingPageLayout = ({ children, title, titleInterpolation, thirdPartyBranding }: Props) => {
const { experienceSettings, theme } = useContext(PageContext);

if (!experienceSettings) {
return null;
Expand All @@ -41,24 +35,19 @@ const LandingPageLayout = ({
} = experienceSettings;

return (
<>
<PageMeta titleKey={title} titleKeyInterpolation={titleInterpolation} />
{platform === 'web' && <div className={styles.placeholderTop} />}
<div className={classNames(styles.wrapper, className)}>
<BrandingHeader
className={classNames(styles.header, layoutClassNames.brandingHeader)}
headline={title}
headlineInterpolation={titleInterpolation}
logo={getBrandingLogoUrl({ theme, branding, isDarkModeEnabled })}
thirdPartyLogo={
thirdPartyBranding &&
getBrandingLogoUrl({ theme, branding: thirdPartyBranding, isDarkModeEnabled })
}
/>
{children}
</div>
{platform === 'web' && <div className={styles.placeholderBottom} />}
</>
<FirstScreenLayout pageMeta={{ titleKey: title, titleKeyInterpolation: titleInterpolation }}>
<BrandingHeader
className={classNames(styles.header, layoutClassNames.brandingHeader)}
headline={title}
headlineInterpolation={titleInterpolation}
logo={getBrandingLogoUrl({ theme, branding, isDarkModeEnabled })}
thirdPartyLogo={
thirdPartyBranding &&
getBrandingLogoUrl({ theme, branding: thirdPartyBranding, isDarkModeEnabled })
}
/>
{children}
</FirstScreenLayout>
);
};

Expand Down
2 changes: 1 addition & 1 deletion packages/experience/src/components/PageMeta/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type TFuncKey } from 'i18next';
import { Helmet } from 'react-helmet';
import { useTranslation } from 'react-i18next';

type Props = {
export type Props = {
readonly titleKey: TFuncKey;
readonly titleKeyInterpolation?: Record<string, unknown>;
};
Expand Down
4 changes: 4 additions & 0 deletions packages/experience/src/constants/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { yes } from '@silverhand/essentials';

export const isDevFeaturesEnabled =
process.env.NODE_ENV !== 'production' || yes(process.env.DEV_FEATURES_ENABLED);
22 changes: 22 additions & 0 deletions packages/experience/src/hooks/use-identifier-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useSearchParams } from 'react-router-dom';

import { identifierSearchParamGuard } from '@/types/guard';
/**
* Extracts and validates sign-in identifiers from URL search parameters.
*
* Functionality:
* 1. Extracts all 'identifier' values from the URL search parameters.
* 2. Validates these values to ensure they are valid `SignInIdentifier`.
* 3. Returns an array of validated sign-in identifiers.
*/
const useIdentifierParams = () => {
const [searchParams] = useSearchParams();

// Todo @xiaoyijun use a constant for the key

Check warning on line 15 in packages/experience/src/hooks/use-identifier-params.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/experience/src/hooks/use-identifier-params.ts#L15

[no-warning-comments] Unexpected 'todo' comment: 'Todo @xiaoyijun use a constant for the...'.
const rawIdentifiers = searchParams.getAll('identifier');
const [_, identifiers = []] = identifierSearchParamGuard.validate(rawIdentifiers);

return { identifiers };
};

export default useIdentifierParams;
56 changes: 56 additions & 0 deletions packages/experience/src/pages/IdentifierSignIn/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { AgreeToTermsPolicy, experience } from '@logto/schemas';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';

import IdentifierPageLayout from '@/Layout/IdentifierPageLayout';
import { identifierInputDescriptionMap } from '@/utils/form';

import IdentifierSignInForm from '../SignIn/IdentifierSignInForm';
import PasswordSignInForm from '../SignIn/PasswordSignInForm';

import useIdentifierSignInMethods from './use-identifier-sign-in-methods';

const IdentifierSignIn = () => {
const { t } = useTranslation();

const signInMethods = useIdentifierSignInMethods();

const isPasswordOnly = useMemo(
() =>
signInMethods.length > 0 &&
signInMethods.every(({ password, verificationCode }) => password && !verificationCode),
[signInMethods]
);

// Fallback to sign-in page if no sign-in methods are available
if (signInMethods.length === 0) {
return <Navigate to={`/${experience.routes.signIn}`} />;
}

return (
<IdentifierPageLayout
pageMeta={{ titleKey: 'description.sign_in' }}
title="description.sign_in"
description={t('description.identifier_sign_in_description', {
types: signInMethods.map(({ identifier }) => t(identifierInputDescriptionMap[identifier])),
})}
footerTermsDisplayPolicies={[
AgreeToTermsPolicy.Automatic,
AgreeToTermsPolicy.ManualRegistrationOnly,
]}
authOptionsLink={{
to: `/${experience.routes.signIn}`,
text: 'description.all_sign_in_options',
}}
>
{isPasswordOnly ? (
<PasswordSignInForm signInMethods={signInMethods.map(({ identifier }) => identifier)} />
) : (
<IdentifierSignInForm signInMethods={signInMethods} />
)}
</IdentifierPageLayout>
);
};

export default IdentifierSignIn;
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useMemo } from 'react';

import useIdentifierParams from '@/hooks/use-identifier-params';
import { useSieMethods } from '@/hooks/use-sie';

/**
* Read sign-in methods from sign-in experience config and URL identifier parameters.
*
* Sign-in methods fallback logic:
* 1. If no identifiers are provided in the URL, return all sign-in methods from sign-in experience config.
* 2. If identifiers are provided in the URL but all of them are not supported by the sign-in experience config, return all sign-in methods from sign-in experience config.
* 3. If identifiers are provided in the URL and supported by the sign-in experience config, return the intersection of the two.
*/
const useIdentifierSignInMethods = () => {
const { signInMethods } = useSieMethods();
const { identifiers } = useIdentifierParams();

return useMemo(() => {
// Fallback to all sign-in methods if no identifiers are provided
if (identifiers.length === 0) {
return signInMethods;
}

const methods = signInMethods.filter(({ identifier }) => identifiers.includes(identifier));

// Fallback to all sign-in methods if no identifiers are supported
if (methods.length === 0) {
return signInMethods;
}

return methods;
}, [identifiers, signInMethods]);
};

export default useIdentifierSignInMethods;
Loading

0 comments on commit 9948ae6

Please sign in to comment.