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): add identifier sign-in page #6435

Merged
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
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
Loading