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:
parent
da58dec022
commit
d9589c88e8
28 changed files with 337 additions and 32 deletions
|
@ -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>
|
||||
|
|
|
@ -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
packages/experience/src/Layout/FirstScreenLayout/index.tsx
Normal file
28
packages/experience/src/Layout/FirstScreenLayout/index.tsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
4
packages/experience/src/constants/env.ts
Normal file
4
packages/experience/src/constants/env.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { yes } from '@silverhand/essentials';
|
||||
|
||||
export const isDevFeaturesEnabled =
|
||||
process.env.NODE_ENV !== 'production' || yes(process.env.DEV_FEATURES_ENABLED);
|
22
packages/experience/src/hooks/use-identifier-params.ts
Normal file
22
packages/experience/src/hooks/use-identifier-params.ts
Normal 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;
|
56
packages/experience/src/pages/IdentifierSignIn/index.tsx
Normal file
56
packages/experience/src/pages/IdentifierSignIn/index.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Add table
Reference in a new issue