0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

feat(experience): add identifier sign-in page (#6435)

This commit is contained in:
Xiao Yijun 2024-08-13 15:05:36 +08:00 committed by GitHub
parent da58dec022
commit d9589c88e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 337 additions and 32 deletions

View file

@ -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';
@ -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>

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;
@ -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>
);
};

View file

@ -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>;
};

View file

@ -0,0 +1,4 @@
import { yes } from '@silverhand/essentials';
export const isDevFeaturesEnabled =
process.env.NODE_ENV !== 'production' || yes(process.env.DEV_FEATURES_ENABLED);

View file

@ -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
const rawIdentifiers = searchParams.getAll('identifier');
const [, identifiers = []] = identifierSearchParamGuard.validate(rawIdentifiers);
return { identifiers };
};
export default useIdentifierParams;

View file

@ -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;

View file

@ -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;

View file

@ -106,6 +106,11 @@ export const ssoConnectorMetadataGuard: s.Describe<SsoConnectorMetadata> = s.obj
connectorName: s.string(),
});
const identifierEnumGuard = s.enums([
SignInIdentifier.Email,
SignInIdentifier.Phone,
SignInIdentifier.Username,
]);
/**
* Defines the type guard for user identifier input value caching.
*
@ -117,8 +122,11 @@ export const ssoConnectorMetadataGuard: s.Describe<SsoConnectorMetadata> = s.obj
* page or the password page, the identifier they entered will not be cleared.
*/
export const identifierInputValueGuard: s.Describe<IdentifierInputValue> = s.object({
type: s.optional(
s.enums([SignInIdentifier.Email, SignInIdentifier.Phone, SignInIdentifier.Username])
),
type: s.optional(identifierEnumGuard),
value: s.string(),
});
/**
* Type guard for the `identifier` search param config on the identifier sign-in/register page.
*/
export const identifierSearchParamGuard = s.array(identifierEnumGuard);

View file

@ -102,6 +102,9 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Indem Sie fortfahren, stimmen Sie den <link></link> zu.',
identifier_sign_in_description:
'Geben Sie Ihre {{types, list(type: disjunction;)}} ein, um sich anzumelden.',
all_sign_in_options: 'Alle Anmeldeoptionen',
};
export default Object.freeze(description);

View file

@ -88,6 +88,8 @@ const description = {
user_id: 'User ID: {{id}}',
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'By continuing, you agree to the <link></link>.',
identifier_sign_in_description: 'Enter you {{types, list(type: disjunction;)}} to sign in.',
all_sign_in_options: 'All sign-in options',
};
export default Object.freeze(description);

View file

@ -102,6 +102,9 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Al continuar, acepta los <link></link>.',
identifier_sign_in_description:
'Ingrese su {{types, list(type: disjunction;)}} para iniciar sesión.',
all_sign_in_options: 'Todas las opciones de inicio de sesión',
};
export default Object.freeze(description);

View file

@ -102,6 +102,9 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'En continuant, vous acceptez les <link></link>.',
identifier_sign_in_description:
'Entrez votre {{types, list(type: disjunction;)}} pour vous connecter.',
all_sign_in_options: 'Toutes les options de connexion',
};
export default Object.freeze(description);

View file

@ -99,6 +99,9 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Continuando, accetti i <link></link>.',
identifier_sign_in_description:
'Inserisci il tuo {{types, list(type: disjunction;)}} per accedere.',
all_sign_in_options: 'Tutte le opzioni di accesso',
};
export default Object.freeze(description);

View file

@ -99,6 +99,8 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: '続行することで、<link></link>に同意したことになります。',
identifier_sign_in_description: '{{types, list(type: disjunction;)}}を入力してサインインします。',
all_sign_in_options: 'すべてのサインインオプション',
};
export default Object.freeze(description);

View file

@ -93,6 +93,9 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: '계속 진행하면 <link></link>에 동의하는 것입니다.',
identifier_sign_in_description:
'로그인하려면 {{types, list(type: disjunction;)}}을(를) 입력하세요.',
all_sign_in_options: '모든 로그인 옵션',
};
export default Object.freeze(description);

View file

@ -100,6 +100,9 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Kontynuując, zgadzasz się na <link></link>.',
identifier_sign_in_description:
'Wprowadź swoje {{types, list(type: disjunction;)}} aby się zalogować.',
all_sign_in_options: 'Wszystkie opcje logowania',
};
export default Object.freeze(description);

View file

@ -97,6 +97,8 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Ao continuar, você concorda com os <link></link>.',
identifier_sign_in_description: 'Digite seu {{types, list(type: disjunction;)}} para entrar.',
all_sign_in_options: 'Todas as opções de login',
};
export default Object.freeze(description);

View file

@ -97,6 +97,9 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Ao continuar, você concorda com os <link></link>.',
identifier_sign_in_description:
'Introduza o seu {{types, list(type: disjunction;)}} para iniciar sessão.',
all_sign_in_options: 'Todas as opções de início de sessão',
};
export default Object.freeze(description);

View file

@ -101,6 +101,8 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Продолжая, вы соглашаетесь с <link></link>.',
identifier_sign_in_description: 'Введите свои {{types, list(type: disjunction;)}} для входа.',
all_sign_in_options: 'Все варианты входа',
};
export default Object.freeze(description);

View file

@ -97,6 +97,8 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: 'Devam ederek <link></link> kabul etmiş oluyorsunuz.',
identifier_sign_in_description: 'Oturum açmak için {{types, list(type: disjunction;)}} girin.',
all_sign_in_options: 'Tüm oturum açma seçenekleri',
};
export default Object.freeze(description);

View file

@ -77,6 +77,8 @@ const description = {
user_id: '用户 ID: {{id}}',
redirect_to: '你将被重定向到 {{name}}。',
auto_agreement: '继续即表示您同意<link></link>。',
identifier_sign_in_description: '输入您的{{types, list(type: disjunction;)}}以登录。',
all_sign_in_options: '所有登录选项',
};
export default Object.freeze(description);

View file

@ -89,6 +89,8 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: '繼續即表示您同意<link></link>。',
identifier_sign_in_description: '輸入您的{{types, list(type: disjunction;)}}以登入。',
all_sign_in_options: '所有登入選項',
};
export default Object.freeze(description);

View file

@ -89,6 +89,8 @@ const description = {
/** UNTRANSLATED */
redirect_to: 'You will be redirected to {{name}}.',
auto_agreement: '繼續即表示您同意<link></link>。',
identifier_sign_in_description: '輸入您的{{types, list(type: disjunction;)}}以登入。',
all_sign_in_options: '所有登入選項',
};
export default Object.freeze(description);

View file

@ -3,6 +3,7 @@ const routes = Object.freeze({
register: 'register',
sso: 'single-sign-on',
consent: 'consent',
identifierSignIn: 'identifier-sign-in',
});
export const experience = Object.freeze({