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(
|
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', () => {
|
||||||
|
|
|
@ -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(':');
|
||||||
|
|
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 { 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 };
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}>;
|
}>;
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
Loading…
Reference in a new issue