diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx index 3880823a57b..56e03faa518 100644 --- a/packages/experience/src/App.tsx +++ b/packages/experience/src/App.tsx @@ -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 */} } /> + {isDevFeaturesEnabled && ( + <> + {/* Identifier sign-in */} + } + /> + + )} + } /> diff --git a/packages/experience/src/Layout/FirstScreenLayout/index.module.scss b/packages/experience/src/Layout/FirstScreenLayout/index.module.scss new file mode 100644 index 00000000000..18ee6754532 --- /dev/null +++ b/packages/experience/src/Layout/FirstScreenLayout/index.module.scss @@ -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; + } +} diff --git a/packages/experience/src/Layout/FirstScreenLayout/index.tsx b/packages/experience/src/Layout/FirstScreenLayout/index.tsx new file mode 100644 index 00000000000..8aaa76165f2 --- /dev/null +++ b/packages/experience/src/Layout/FirstScreenLayout/index.tsx @@ -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 ( + <> + + {platform === 'web' &&
} +
{children}
+ {platform === 'web' &&
} + + ); +}; + +export default FirstScreenLayout; diff --git a/packages/experience/src/Layout/IdentifierPageLayout/index.module.scss b/packages/experience/src/Layout/IdentifierPageLayout/index.module.scss new file mode 100644 index 00000000000..bf8b5680d9f --- /dev/null +++ b/packages/experience/src/Layout/IdentifierPageLayout/index.module.scss @@ -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; + } +} diff --git a/packages/experience/src/Layout/IdentifierPageLayout/index.tsx b/packages/experience/src/Layout/IdentifierPageLayout/index.tsx new file mode 100644 index 00000000000..2f73d9c9d7e --- /dev/null +++ b/packages/experience/src/Layout/IdentifierPageLayout/index.tsx @@ -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 ( + +
+
+ +
+
{description}
+
+ {children} + {shouldDisplayFooterTerms && } + +
+ ); +}; + +export default IdentifierPageLayout; diff --git a/packages/experience/src/Layout/LandingPageLayout/index.tsx b/packages/experience/src/Layout/LandingPageLayout/index.tsx index 06cb52fcb78..dc40e0006a4 100644 --- a/packages/experience/src/Layout/LandingPageLayout/index.tsx +++ b/packages/experience/src/Layout/LandingPageLayout/index.tsx @@ -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; 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 ( - <> - - {platform === 'web' &&
} -
- - {children} -
- {platform === 'web' &&
} - + + + {children} + ); }; diff --git a/packages/experience/src/components/PageMeta/index.tsx b/packages/experience/src/components/PageMeta/index.tsx index 04414bedb40..72a6d31ad94 100644 --- a/packages/experience/src/components/PageMeta/index.tsx +++ b/packages/experience/src/components/PageMeta/index.tsx @@ -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; }; diff --git a/packages/experience/src/constants/env.ts b/packages/experience/src/constants/env.ts new file mode 100644 index 00000000000..d30c92c950e --- /dev/null +++ b/packages/experience/src/constants/env.ts @@ -0,0 +1,4 @@ +import { yes } from '@silverhand/essentials'; + +export const isDevFeaturesEnabled = + process.env.NODE_ENV !== 'production' || yes(process.env.DEV_FEATURES_ENABLED); diff --git a/packages/experience/src/hooks/use-identifier-params.ts b/packages/experience/src/hooks/use-identifier-params.ts new file mode 100644 index 00000000000..80e95c56c69 --- /dev/null +++ b/packages/experience/src/hooks/use-identifier-params.ts @@ -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; diff --git a/packages/experience/src/pages/IdentifierSignIn/index.tsx b/packages/experience/src/pages/IdentifierSignIn/index.tsx new file mode 100644 index 00000000000..d4e55797d2a --- /dev/null +++ b/packages/experience/src/pages/IdentifierSignIn/index.tsx @@ -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 ; + } + + return ( + t(identifierInputDescriptionMap[identifier])), + })} + footerTermsDisplayPolicies={[ + AgreeToTermsPolicy.Automatic, + AgreeToTermsPolicy.ManualRegistrationOnly, + ]} + authOptionsLink={{ + to: `/${experience.routes.signIn}`, + text: 'description.all_sign_in_options', + }} + > + {isPasswordOnly ? ( + identifier)} /> + ) : ( + + )} + + ); +}; + +export default IdentifierSignIn; diff --git a/packages/experience/src/pages/IdentifierSignIn/use-identifier-sign-in-methods.ts b/packages/experience/src/pages/IdentifierSignIn/use-identifier-sign-in-methods.ts new file mode 100644 index 00000000000..a5fd57d88e7 --- /dev/null +++ b/packages/experience/src/pages/IdentifierSignIn/use-identifier-sign-in-methods.ts @@ -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; diff --git a/packages/experience/src/types/guard.ts b/packages/experience/src/types/guard.ts index b9b13e1b772..03837d4d68e 100644 --- a/packages/experience/src/types/guard.ts +++ b/packages/experience/src/types/guard.ts @@ -106,6 +106,11 @@ export const ssoConnectorMetadataGuard: s.Describe = 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 = s.obj * page or the password page, the identifier they entered will not be cleared. */ export const identifierInputValueGuard: s.Describe = 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); diff --git a/packages/phrases-experience/src/locales/de/description.ts b/packages/phrases-experience/src/locales/de/description.ts index 1a6780ade4d..bde66dd9c3c 100644 --- a/packages/phrases-experience/src/locales/de/description.ts +++ b/packages/phrases-experience/src/locales/de/description.ts @@ -102,6 +102,9 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: 'Indem Sie fortfahren, stimmen Sie den 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); diff --git a/packages/phrases-experience/src/locales/en/description.ts b/packages/phrases-experience/src/locales/en/description.ts index b22b4db72ad..8689e7e1e2a 100644 --- a/packages/phrases-experience/src/locales/en/description.ts +++ b/packages/phrases-experience/src/locales/en/description.ts @@ -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 .', + 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); diff --git a/packages/phrases-experience/src/locales/es/description.ts b/packages/phrases-experience/src/locales/es/description.ts index d9c91a7738f..fbec1edf258 100644 --- a/packages/phrases-experience/src/locales/es/description.ts +++ b/packages/phrases-experience/src/locales/es/description.ts @@ -102,6 +102,9 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: 'Al continuar, acepta los .', + 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); diff --git a/packages/phrases-experience/src/locales/fr/description.ts b/packages/phrases-experience/src/locales/fr/description.ts index aaed9bbf9bb..867e96ec756 100644 --- a/packages/phrases-experience/src/locales/fr/description.ts +++ b/packages/phrases-experience/src/locales/fr/description.ts @@ -102,6 +102,9 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: 'En continuant, vous acceptez les .', + 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); diff --git a/packages/phrases-experience/src/locales/it/description.ts b/packages/phrases-experience/src/locales/it/description.ts index 13fc169e5f1..8c5fa999e46 100644 --- a/packages/phrases-experience/src/locales/it/description.ts +++ b/packages/phrases-experience/src/locales/it/description.ts @@ -99,6 +99,9 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: 'Continuando, accetti i .', + 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); diff --git a/packages/phrases-experience/src/locales/ja/description.ts b/packages/phrases-experience/src/locales/ja/description.ts index b7b034cac0c..4bf41550720 100644 --- a/packages/phrases-experience/src/locales/ja/description.ts +++ b/packages/phrases-experience/src/locales/ja/description.ts @@ -99,6 +99,8 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: '続行することで、に同意したことになります。', + identifier_sign_in_description: '{{types, list(type: disjunction;)}}を入力してサインインします。', + all_sign_in_options: 'すべてのサインインオプション', }; export default Object.freeze(description); diff --git a/packages/phrases-experience/src/locales/ko/description.ts b/packages/phrases-experience/src/locales/ko/description.ts index 0216c0dbdd8..460663a2c99 100644 --- a/packages/phrases-experience/src/locales/ko/description.ts +++ b/packages/phrases-experience/src/locales/ko/description.ts @@ -93,6 +93,9 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: '계속 진행하면 에 동의하는 것입니다.', + identifier_sign_in_description: + '로그인하려면 {{types, list(type: disjunction;)}}을(를) 입력하세요.', + all_sign_in_options: '모든 로그인 옵션', }; export default Object.freeze(description); diff --git a/packages/phrases-experience/src/locales/pl-pl/description.ts b/packages/phrases-experience/src/locales/pl-pl/description.ts index fc06aaf41ad..8ec9d862dcd 100644 --- a/packages/phrases-experience/src/locales/pl-pl/description.ts +++ b/packages/phrases-experience/src/locales/pl-pl/description.ts @@ -100,6 +100,9 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: 'Kontynuując, zgadzasz się na .', + 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); diff --git a/packages/phrases-experience/src/locales/pt-br/description.ts b/packages/phrases-experience/src/locales/pt-br/description.ts index 26f34d2e3a2..2bfa3756165 100644 --- a/packages/phrases-experience/src/locales/pt-br/description.ts +++ b/packages/phrases-experience/src/locales/pt-br/description.ts @@ -97,6 +97,8 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: 'Ao continuar, você concorda com os .', + 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); diff --git a/packages/phrases-experience/src/locales/pt-pt/description.ts b/packages/phrases-experience/src/locales/pt-pt/description.ts index b5b480d9931..70a3d64ad0a 100644 --- a/packages/phrases-experience/src/locales/pt-pt/description.ts +++ b/packages/phrases-experience/src/locales/pt-pt/description.ts @@ -97,6 +97,9 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: 'Ao continuar, você concorda com os .', + 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); diff --git a/packages/phrases-experience/src/locales/ru/description.ts b/packages/phrases-experience/src/locales/ru/description.ts index 91ab68a3310..dab9966c37c 100644 --- a/packages/phrases-experience/src/locales/ru/description.ts +++ b/packages/phrases-experience/src/locales/ru/description.ts @@ -101,6 +101,8 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: 'Продолжая, вы соглашаетесь с .', + identifier_sign_in_description: 'Введите свои {{types, list(type: disjunction;)}} для входа.', + all_sign_in_options: 'Все варианты входа', }; export default Object.freeze(description); diff --git a/packages/phrases-experience/src/locales/tr-tr/description.ts b/packages/phrases-experience/src/locales/tr-tr/description.ts index 2e6f98ffb66..cecb875900e 100644 --- a/packages/phrases-experience/src/locales/tr-tr/description.ts +++ b/packages/phrases-experience/src/locales/tr-tr/description.ts @@ -97,6 +97,8 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: 'Devam ederek 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); diff --git a/packages/phrases-experience/src/locales/zh-cn/description.ts b/packages/phrases-experience/src/locales/zh-cn/description.ts index 884b0c7964d..f0c9b23ed5b 100644 --- a/packages/phrases-experience/src/locales/zh-cn/description.ts +++ b/packages/phrases-experience/src/locales/zh-cn/description.ts @@ -77,6 +77,8 @@ const description = { user_id: '用户 ID: {{id}}', redirect_to: '你将被重定向到 {{name}}。', auto_agreement: '继续即表示您同意。', + identifier_sign_in_description: '输入您的{{types, list(type: disjunction;)}}以登录。', + all_sign_in_options: '所有登录选项', }; export default Object.freeze(description); diff --git a/packages/phrases-experience/src/locales/zh-hk/description.ts b/packages/phrases-experience/src/locales/zh-hk/description.ts index 1e28053f98f..1ef2ce8bd30 100644 --- a/packages/phrases-experience/src/locales/zh-hk/description.ts +++ b/packages/phrases-experience/src/locales/zh-hk/description.ts @@ -89,6 +89,8 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: '繼續即表示您同意。', + identifier_sign_in_description: '輸入您的{{types, list(type: disjunction;)}}以登入。', + all_sign_in_options: '所有登入選項', }; export default Object.freeze(description); diff --git a/packages/phrases-experience/src/locales/zh-tw/description.ts b/packages/phrases-experience/src/locales/zh-tw/description.ts index f4cbe0fa059..23c706afda8 100644 --- a/packages/phrases-experience/src/locales/zh-tw/description.ts +++ b/packages/phrases-experience/src/locales/zh-tw/description.ts @@ -89,6 +89,8 @@ const description = { /** UNTRANSLATED */ redirect_to: 'You will be redirected to {{name}}.', auto_agreement: '繼續即表示您同意。', + identifier_sign_in_description: '輸入您的{{types, list(type: disjunction;)}}以登入。', + all_sign_in_options: '所有登入選項', }; export default Object.freeze(description); diff --git a/packages/schemas/src/consts/experience.ts b/packages/schemas/src/consts/experience.ts index bc84b6fa15e..c992636b6ed 100644 --- a/packages/schemas/src/consts/experience.ts +++ b/packages/schemas/src/consts/experience.ts @@ -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({