mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core,experience,schemas): support identifier page related params for sign-in url (#6446)
This commit is contained in:
parent
737204e54f
commit
f17526c612
7 changed files with 147 additions and 13 deletions
|
@ -150,9 +150,22 @@ describe('buildLoginPromptUrl', () => {
|
|||
expect(
|
||||
buildLoginPromptUrl({ first_screen: FirstScreen.SignIn, login_hint: 'user@mail.com' })
|
||||
).toBe('sign-in?login_hint=user%40mail.com');
|
||||
expect(
|
||||
buildLoginPromptUrl({ first_screen: FirstScreen.IdentifierSignIn, identifier: 'email phone' })
|
||||
).toBe('identifier-sign-in?identifier=email+phone');
|
||||
expect(
|
||||
buildLoginPromptUrl({
|
||||
first_screen: FirstScreen.IdentifierRegister,
|
||||
identifier: 'username',
|
||||
})
|
||||
).toBe('identifier-register?identifier=username');
|
||||
expect(buildLoginPromptUrl({ first_screen: FirstScreen.SingleSignOn })).toBe('single-sign-on');
|
||||
|
||||
// Legacy interactionMode support
|
||||
expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register');
|
||||
|
||||
// Legacy FirstScreen.SignInDeprecated support
|
||||
expect(buildLoginPromptUrl({ first_screen: FirstScreen.SignInDeprecated })).toBe('sign-in');
|
||||
});
|
||||
|
||||
it('should return the correct url for directSignIn', () => {
|
||||
|
|
|
@ -12,7 +12,9 @@ import {
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { type AllClientMetadata, type ClientAuthMethod, errors } from 'oidc-provider';
|
||||
|
||||
import type { EnvSet } from '#src/env-set/index.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
|
||||
const { isDevFeaturesEnabled } = EnvSet.values;
|
||||
|
||||
export const getConstantClientMetadata = (
|
||||
envSet: EnvSet,
|
||||
|
@ -86,13 +88,32 @@ export const getUtcStartOfTheDay = (date: Date) => {
|
|||
);
|
||||
};
|
||||
|
||||
const firstScreenRouteMapping: Record<FirstScreen, keyof typeof experience.routes> = {
|
||||
[FirstScreen.SignIn]: 'signIn',
|
||||
[FirstScreen.Register]: 'register',
|
||||
/**
|
||||
* Todo @xiaoyijun remove isDevFeaturesEnabled check
|
||||
* Fallback to signIn when dev feature is not ready (these three screens are not supported yet)
|
||||
*/
|
||||
[FirstScreen.IdentifierSignIn]: isDevFeaturesEnabled ? 'identifierSignIn' : 'signIn',
|
||||
[FirstScreen.IdentifierRegister]: isDevFeaturesEnabled ? 'identifierRegister' : 'signIn',
|
||||
[FirstScreen.SingleSignOn]: isDevFeaturesEnabled ? 'sso' : 'signIn',
|
||||
[FirstScreen.SignInDeprecated]: 'signIn',
|
||||
};
|
||||
|
||||
// Note: this eslint comment can be removed once the dev feature flag is removed
|
||||
// eslint-disable-next-line complexity
|
||||
export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown): string => {
|
||||
const firstScreenKey =
|
||||
params[ExtraParamsKey.FirstScreen] ??
|
||||
params[ExtraParamsKey.InteractionMode] ??
|
||||
FirstScreen.SignIn;
|
||||
|
||||
const firstScreen =
|
||||
firstScreenKey === 'signUp' ? experience.routes.register : experience.routes[firstScreenKey];
|
||||
firstScreenKey === 'signUp'
|
||||
? experience.routes.register
|
||||
: experience.routes[firstScreenRouteMapping[firstScreenKey]];
|
||||
|
||||
const directSignIn = params[ExtraParamsKey.DirectSignIn];
|
||||
const searchParams = new URLSearchParams();
|
||||
const getSearchParamString = () => (searchParams.size > 0 ? `?${searchParams.toString()}` : '');
|
||||
|
@ -109,6 +130,13 @@ export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown):
|
|||
searchParams.append(ExtraParamsKey.LoginHint, params[ExtraParamsKey.LoginHint]);
|
||||
}
|
||||
|
||||
if (isDevFeaturesEnabled) {
|
||||
// eslint-disable-next-line unicorn/no-lonely-if
|
||||
if (params[ExtraParamsKey.Identifier]) {
|
||||
searchParams.append(ExtraParamsKey.Identifier, params[ExtraParamsKey.Identifier]);
|
||||
}
|
||||
}
|
||||
|
||||
if (directSignIn) {
|
||||
searchParams.append('fallback', firstScreen);
|
||||
const [method, target] = directSignIn.split(':');
|
||||
|
|
55
packages/experience/src/hooks/use-identifier-params.test.ts
Normal file
55
packages/experience/src/hooks/use-identifier-params.test.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { ExtraParamsKey, SignInIdentifier } from '@logto/schemas';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import * as reactRouterDom from 'react-router-dom';
|
||||
|
||||
import useIdentifierParams from './use-identifier-params';
|
||||
|
||||
// Mock the react-router-dom module
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useSearchParams: jest.fn(),
|
||||
}));
|
||||
|
||||
// Helper function to mock search params
|
||||
const mockSearchParams = (params: Record<string, string>) => {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
(reactRouterDom.useSearchParams as jest.Mock).mockReturnValue([searchParams]);
|
||||
};
|
||||
|
||||
describe('useIdentifierParams', () => {
|
||||
it('should return an empty array when no identifiers are provided', () => {
|
||||
mockSearchParams({});
|
||||
const { result } = renderHook(() => useIdentifierParams());
|
||||
expect(result.current.identifiers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse and validate a single identifier', () => {
|
||||
mockSearchParams({ [ExtraParamsKey.Identifier]: 'email' });
|
||||
const { result } = renderHook(() => useIdentifierParams());
|
||||
expect(result.current.identifiers).toEqual([SignInIdentifier.Email]);
|
||||
});
|
||||
|
||||
it('should parse and validate multiple identifiers', () => {
|
||||
mockSearchParams({ [ExtraParamsKey.Identifier]: 'email phone' });
|
||||
const { result } = renderHook(() => useIdentifierParams());
|
||||
expect(result.current.identifiers).toEqual([SignInIdentifier.Email, SignInIdentifier.Phone]);
|
||||
});
|
||||
|
||||
it('should filter out invalid identifiers', () => {
|
||||
mockSearchParams({ [ExtraParamsKey.Identifier]: 'email invalid phone' });
|
||||
const { result } = renderHook(() => useIdentifierParams());
|
||||
expect(result.current.identifiers).toEqual([SignInIdentifier.Email, SignInIdentifier.Phone]);
|
||||
});
|
||||
|
||||
it('should handle empty string input', () => {
|
||||
mockSearchParams({ [ExtraParamsKey.Identifier]: '' });
|
||||
const { result } = renderHook(() => useIdentifierParams());
|
||||
expect(result.current.identifiers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle identifiers with extra spaces', () => {
|
||||
mockSearchParams({ [ExtraParamsKey.Identifier]: ' email phone ' });
|
||||
const { result } = renderHook(() => useIdentifierParams());
|
||||
expect(result.current.identifiers).toEqual([SignInIdentifier.Email, SignInIdentifier.Phone]);
|
||||
});
|
||||
});
|
|
@ -1,20 +1,37 @@
|
|||
import { ExtraParamsKey, type SignInIdentifier, signInIdentifierGuard } from '@logto/schemas';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { identifierSearchParamGuard } from '@/types/guard';
|
||||
/**
|
||||
* Extracts and validates sign-in identifiers from URL search parameters.
|
||||
* Parses and validates a string of space-separated identifiers.
|
||||
*
|
||||
* @param value - A string containing space-separated identifiers (e.g., "email phone").
|
||||
* @returns An array of validated SignInIdentifier objects.
|
||||
*/
|
||||
const parseIdentifierParamValue = (value: string): SignInIdentifier[] => {
|
||||
const identifiers = value.split(' ');
|
||||
|
||||
return identifiers.reduce<SignInIdentifier[]>((result, identifier) => {
|
||||
const parsed = signInIdentifierGuard.safeParse(identifier);
|
||||
return parsed.success ? [...result, parsed.data] : result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to extract and validate 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.
|
||||
* 1. Extracts the 'identifier' value from the URL search parameters.
|
||||
* 2. Parses the identifier string, which is expected to be in the format "email phone",
|
||||
* where multiple identifiers are separated by spaces.
|
||||
* 3. Validates each parsed identifier to ensure it is a valid `SignInIdentifier`.
|
||||
* 4. Returns an array of validated sign-in identifiers.
|
||||
*
|
||||
* @returns An object containing the array of parsed and validated identifiers.
|
||||
*/
|
||||
const useIdentifierParams = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Todo @xiaoyijun use a constant for the key
|
||||
const rawIdentifiers = searchParams.getAll('identifier');
|
||||
const [, identifiers = []] = identifierSearchParamGuard.validate(rawIdentifiers);
|
||||
const identifiers = parseIdentifierParamValue(searchParams.get(ExtraParamsKey.Identifier) ?? '');
|
||||
|
||||
return { identifiers };
|
||||
};
|
||||
|
|
|
@ -5,8 +5,8 @@ const routes = Object.freeze({
|
|||
consent: 'consent',
|
||||
identifierSignIn: 'identifier-sign-in',
|
||||
identifierRegister: 'identifier-register',
|
||||
});
|
||||
} as const);
|
||||
|
||||
export const experience = Object.freeze({
|
||||
routes,
|
||||
});
|
||||
} as const);
|
||||
|
|
|
@ -46,6 +46,18 @@ export enum ExtraParamsKey {
|
|||
* 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',
|
||||
/**
|
||||
* Specifies the identifier used in the identifier sign-in or identifier register page.
|
||||
*
|
||||
* This parameter is applicable only when first_screen is set to either `FirstScreen.IdentifierSignIn` or `FirstScreen.IdentifierRegister`.
|
||||
* Multiple identifiers can be provided in the identifier parameter, separated by spaces.
|
||||
*
|
||||
* If the provided identifier is not supported in the Logto sign-in experience configuration, it will be ignored,
|
||||
* and if no one of them is supported, it will fallback to the sign-in / sign-up method value set in the sign-in experience configuration.
|
||||
*
|
||||
* @see {@link SignInIdentifier} for available values.
|
||||
*/
|
||||
Identifier = 'identifier',
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link FirstScreen} instead. */
|
||||
|
@ -55,8 +67,13 @@ export enum InteractionMode {
|
|||
}
|
||||
|
||||
export enum FirstScreen {
|
||||
SignIn = 'signIn',
|
||||
SignIn = 'sign_in',
|
||||
Register = 'register',
|
||||
IdentifierSignIn = 'identifier:sign_in',
|
||||
IdentifierRegister = 'identifier:register',
|
||||
SingleSignOn = 'single_sign_on',
|
||||
/** @deprecated Use snake_case 'sign_in' instead. */
|
||||
SignInDeprecated = 'signIn',
|
||||
}
|
||||
|
||||
export const extraParamsObjectGuard = z
|
||||
|
@ -66,6 +83,7 @@ export const extraParamsObjectGuard = z
|
|||
[ExtraParamsKey.DirectSignIn]: z.string(),
|
||||
[ExtraParamsKey.OrganizationId]: z.string(),
|
||||
[ExtraParamsKey.LoginHint]: z.string(),
|
||||
[ExtraParamsKey.Identifier]: z.string(),
|
||||
})
|
||||
.partial() satisfies ToZodObject<ExtraParamsObject>;
|
||||
|
||||
|
@ -75,4 +93,5 @@ export type ExtraParamsObject = Partial<{
|
|||
[ExtraParamsKey.DirectSignIn]: string;
|
||||
[ExtraParamsKey.OrganizationId]: string;
|
||||
[ExtraParamsKey.LoginHint]: string;
|
||||
[ExtraParamsKey.Identifier]: string;
|
||||
}>;
|
||||
|
|
|
@ -47,6 +47,8 @@ export enum SignInIdentifier {
|
|||
Phone = 'phone',
|
||||
}
|
||||
|
||||
export const signInIdentifierGuard = z.nativeEnum(SignInIdentifier);
|
||||
|
||||
export const signUpGuard = z.object({
|
||||
identifiers: z.nativeEnum(SignInIdentifier).array(),
|
||||
password: z.boolean(),
|
||||
|
|
Loading…
Reference in a new issue