0
Fork 0
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:
Xiao Yijun 2024-08-16 14:55:07 +08:00 committed by GitHub
parent 737204e54f
commit f17526c612
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 147 additions and 13 deletions

View file

@ -150,9 +150,22 @@ describe('buildLoginPromptUrl', () => {
expect( expect(
buildLoginPromptUrl({ first_screen: FirstScreen.SignIn, login_hint: 'user@mail.com' }) buildLoginPromptUrl({ first_screen: FirstScreen.SignIn, login_hint: 'user@mail.com' })
).toBe('sign-in?login_hint=user%40mail.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 // Legacy interactionMode support
expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register'); 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', () => { it('should return the correct url for directSignIn', () => {

View file

@ -12,7 +12,9 @@ import {
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { type AllClientMetadata, type ClientAuthMethod, errors } from 'oidc-provider'; 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 = ( export const getConstantClientMetadata = (
envSet: EnvSet, 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 => { export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown): string => {
const firstScreenKey = const firstScreenKey =
params[ExtraParamsKey.FirstScreen] ?? params[ExtraParamsKey.FirstScreen] ??
params[ExtraParamsKey.InteractionMode] ?? params[ExtraParamsKey.InteractionMode] ??
FirstScreen.SignIn; FirstScreen.SignIn;
const firstScreen = 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 directSignIn = params[ExtraParamsKey.DirectSignIn];
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
const getSearchParamString = () => (searchParams.size > 0 ? `?${searchParams.toString()}` : ''); 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]); 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) { if (directSignIn) {
searchParams.append('fallback', firstScreen); searchParams.append('fallback', firstScreen);
const [method, target] = directSignIn.split(':'); const [method, target] = directSignIn.split(':');

View 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]);
});
});

View file

@ -1,20 +1,37 @@
import { ExtraParamsKey, type SignInIdentifier, signInIdentifierGuard } from '@logto/schemas';
import { useSearchParams } from 'react-router-dom'; 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: * Functionality:
* 1. Extracts all 'identifier' values from the URL search parameters. * 1. Extracts the 'identifier' value from the URL search parameters.
* 2. Validates these values to ensure they are valid `SignInIdentifier`. * 2. Parses the identifier string, which is expected to be in the format "email phone",
* 3. Returns an array of validated sign-in identifiers. * 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 useIdentifierParams = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Todo @xiaoyijun use a constant for the key const identifiers = parseIdentifierParamValue(searchParams.get(ExtraParamsKey.Identifier) ?? '');
const rawIdentifiers = searchParams.getAll('identifier');
const [, identifiers = []] = identifierSearchParamGuard.validate(rawIdentifiers);
return { identifiers }; return { identifiers };
}; };

View file

@ -5,8 +5,8 @@ const routes = Object.freeze({
consent: 'consent', consent: 'consent',
identifierSignIn: 'identifier-sign-in', identifierSignIn: 'identifier-sign-in',
identifierRegister: 'identifier-register', identifierRegister: 'identifier-register',
}); } as const);
export const experience = Object.freeze({ export const experience = Object.freeze({
routes, routes,
}); } as const);

View file

@ -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. * 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', 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. */ /** @deprecated Use {@link FirstScreen} instead. */
@ -55,8 +67,13 @@ export enum InteractionMode {
} }
export enum FirstScreen { export enum FirstScreen {
SignIn = 'signIn', SignIn = 'sign_in',
Register = 'register', 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 export const extraParamsObjectGuard = z
@ -66,6 +83,7 @@ export const extraParamsObjectGuard = z
[ExtraParamsKey.DirectSignIn]: z.string(), [ExtraParamsKey.DirectSignIn]: z.string(),
[ExtraParamsKey.OrganizationId]: z.string(), [ExtraParamsKey.OrganizationId]: z.string(),
[ExtraParamsKey.LoginHint]: z.string(), [ExtraParamsKey.LoginHint]: z.string(),
[ExtraParamsKey.Identifier]: z.string(),
}) })
.partial() satisfies ToZodObject<ExtraParamsObject>; .partial() satisfies ToZodObject<ExtraParamsObject>;
@ -75,4 +93,5 @@ export type ExtraParamsObject = Partial<{
[ExtraParamsKey.DirectSignIn]: string; [ExtraParamsKey.DirectSignIn]: string;
[ExtraParamsKey.OrganizationId]: string; [ExtraParamsKey.OrganizationId]: string;
[ExtraParamsKey.LoginHint]: string; [ExtraParamsKey.LoginHint]: string;
[ExtraParamsKey.Identifier]: string;
}>; }>;

View file

@ -47,6 +47,8 @@ export enum SignInIdentifier {
Phone = 'phone', Phone = 'phone',
} }
export const signInIdentifierGuard = z.nativeEnum(SignInIdentifier);
export const signUpGuard = z.object({ export const signUpGuard = z.object({
identifiers: z.nativeEnum(SignInIdentifier).array(), identifiers: z.nativeEnum(SignInIdentifier).array(),
password: z.boolean(), password: z.boolean(),