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: support login_hint params for sign-in url #6400

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
20 changes: 20 additions & 0 deletions .changeset/popular-monkeys-complain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@logto/experience": minor
"@logto/schemas": minor
"@logto/core": minor
---

add support for `login_hint` parameter in sign-in method
xiaoyijun marked this conversation as resolved.
Show resolved Hide resolved

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: '[email protected]',
firstScreen: 'signIn', // or 'register'
});
```
9 changes: 8 additions & 1 deletion packages/core/src/oidc/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]' })
).toBe('sign-in?login_hint=user%40mail.com');

// Legacy interactionMode support
expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register');
});
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/oidc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(':');
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,6 +38,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)

const { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext);

const [searchParams] = useSearchParams();

const {
watch,
handleSubmit,
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
}
/>
)}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
/>
)}
/>
Expand Down
7 changes: 7 additions & 0 deletions packages/schemas/src/consts/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 **only on the first screen** of the sign-in/sign-up flow.
*/
LoginHint = 'login_hint',
}

/** @deprecated Use {@link FirstScreen} instead. */
Expand All @@ -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<ExtraParamsObject>;

Expand All @@ -68,4 +74,5 @@ export type ExtraParamsObject = Partial<{
[ExtraParamsKey.FirstScreen]: FirstScreen;
[ExtraParamsKey.DirectSignIn]: string;
[ExtraParamsKey.OrganizationId]: string;
[ExtraParamsKey.LoginHint]: string;
}>;
Loading