diff --git a/.changeset/popular-monkeys-complain.md b/.changeset/popular-monkeys-complain.md new file mode 100644 index 000000000000..cb872e38a5d9 --- /dev/null +++ b/.changeset/popular-monkeys-complain.md @@ -0,0 +1,20 @@ +--- +"@logto/experience": minor +"@logto/schemas": minor +"@logto/core": minor +--- + +add support for `login_hint` parameter in sign-in method + +This feature allows you to provide a suggested identifier (email, phone, or username) for the user, improving the sign-in experience especially in scenarios where the user's identifier is known or can be inferred. + +Example: + +```javascript +// Example usage (React project using React SDK) +void signIn({ + redirectUri, + loginHint: 'user@example.com', + firstScreen: 'signIn', // or 'register' +}); +``` diff --git a/packages/core/src/oidc/utils.test.ts b/packages/core/src/oidc/utils.test.ts index 8d7c2b951c06..3ea7565b3653 100644 --- a/packages/core/src/oidc/utils.test.ts +++ b/packages/core/src/oidc/utils.test.ts @@ -147,6 +147,10 @@ describe('buildLoginPromptUrl', () => { expect(buildLoginPromptUrl({ first_screen: FirstScreen.SignIn }, demoAppApplicationId)).toBe( 'sign-in?app_id=demo-app' ); + expect( + buildLoginPromptUrl({ first_screen: FirstScreen.SignIn, login_hint: 'user@mail.com' }) + ).toBe('sign-in?login_hint=user%40mail.com'); + // Legacy interactionMode support expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register'); }); @@ -169,7 +173,10 @@ describe('buildLoginPromptUrl', () => { it('should return the correct url for mixed parameters', () => { expect( - buildLoginPromptUrl({ first_screen: FirstScreen.Register, direct_sign_in: 'method:target' }) + buildLoginPromptUrl({ + first_screen: FirstScreen.Register, + direct_sign_in: 'method:target', + }) ).toBe('direct/method/target?fallback=register'); expect( buildLoginPromptUrl( diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts index 83c4e5f01b17..ec1cf872163f 100644 --- a/packages/core/src/oidc/utils.ts +++ b/packages/core/src/oidc/utils.ts @@ -105,6 +105,10 @@ export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown): searchParams.append(ExtraParamsKey.OrganizationId, params[ExtraParamsKey.OrganizationId]); } + if (params[ExtraParamsKey.LoginHint]) { + searchParams.append(ExtraParamsKey.LoginHint, params[ExtraParamsKey.LoginHint]); + } + if (directSignIn) { searchParams.append('fallback', firstScreen); const [method, target] = directSignIn.split(':'); diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx index 254bf28bdb34..ff11145db3a8 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx @@ -1,8 +1,9 @@ -import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas'; +import { AgreeToTermsPolicy, ExtraParamsKey, type SignInIdentifier } from '@logto/schemas'; import classNames from 'classnames'; import { useCallback, useContext, useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import LockIcon from '@/assets/icons/lock.svg?react'; @@ -37,6 +38,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) const { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext); + const [searchParams] = useSearchParams(); + const { watch, handleSubmit, @@ -117,7 +120,9 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) autoFocus={autoFocus} className={styles.inputField} {...field} - defaultValue={identifierInputValue?.value} + defaultValue={ + identifierInputValue?.value ?? searchParams.get(ExtraParamsKey.LoginHint) ?? undefined + } defaultType={identifierInputValue?.type} isDanger={!!errors.id || !!errorMessage} errorMessage={errors.id?.message} diff --git a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx index b39654907628..994a9d60fa5b 100644 --- a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx +++ b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx @@ -1,8 +1,9 @@ -import { AgreeToTermsPolicy, type SignIn } from '@logto/schemas'; +import { AgreeToTermsPolicy, ExtraParamsKey, type SignIn } from '@logto/schemas'; import classNames from 'classnames'; import { useCallback, useContext, useEffect, useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import LockIcon from '@/assets/icons/lock.svg?react'; @@ -34,6 +35,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(signInMethods); const { termsValidation, agreeToTermsPolicy } = useTerms(); const { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext); + const [searchParams] = useSearchParams(); const enabledSignInMethods = useMemo( () => signInMethods.map(({ identifier }) => identifier), @@ -123,7 +125,9 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => errorMessage={errors.identifier?.message} enabledTypes={enabledSignInMethods} defaultType={identifierInputValue?.type} - defaultValue={identifierInputValue?.value} + defaultValue={ + identifierInputValue?.value ?? searchParams.get(ExtraParamsKey.LoginHint) ?? undefined + } /> )} /> diff --git a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx index 5d6589c6796c..1ec6f1e3a6eb 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx +++ b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx @@ -1,8 +1,9 @@ -import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas'; +import { AgreeToTermsPolicy, ExtraParamsKey, type SignInIdentifier } from '@logto/schemas'; import classNames from 'classnames'; import { useCallback, useContext, useEffect } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import LockIcon from '@/assets/icons/lock.svg?react'; @@ -39,6 +40,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => { const { isForgotPasswordEnabled } = useForgotPasswordSettings(); const { termsValidation, agreeToTermsPolicy } = useTerms(); const { setIdentifierInputValue } = useContext(UserInteractionContext); + const [searchParams] = useSearchParams(); const { watch, @@ -127,6 +129,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => { isDanger={!!errors.identifier} errorMessage={errors.identifier?.message} enabledTypes={signInMethods} + defaultValue={searchParams.get(ExtraParamsKey.LoginHint) ?? undefined} /> )} /> diff --git a/packages/schemas/src/consts/oidc.ts b/packages/schemas/src/consts/oidc.ts index f5cebc10b614..79aa9c867779 100644 --- a/packages/schemas/src/consts/oidc.ts +++ b/packages/schemas/src/consts/oidc.ts @@ -41,6 +41,11 @@ export enum ExtraParamsKey { * organization ID. */ OrganizationId = 'organization_id', + /** + * Provides a hint about the login identifier the user might use. + * This can be used to pre-fill the identifier field **on the first screen** of the sign-in/sign-up flow. + */ + LoginHint = 'login_hint', } /** @deprecated Use {@link FirstScreen} instead. */ @@ -60,6 +65,7 @@ export const extraParamsObjectGuard = z [ExtraParamsKey.FirstScreen]: z.nativeEnum(FirstScreen), [ExtraParamsKey.DirectSignIn]: z.string(), [ExtraParamsKey.OrganizationId]: z.string(), + [ExtraParamsKey.LoginHint]: z.string(), }) .partial() satisfies ToZodObject; @@ -68,4 +74,5 @@ export type ExtraParamsObject = Partial<{ [ExtraParamsKey.FirstScreen]: FirstScreen; [ExtraParamsKey.DirectSignIn]: string; [ExtraParamsKey.OrganizationId]: string; + [ExtraParamsKey.LoginHint]: string; }>;