Skip to content

Commit

Permalink
feat(core,experience,schemas): support identifier page related params…
Browse files Browse the repository at this point in the history
… for sign-in url
  • Loading branch information
xiaoyijun committed Aug 14, 2024
1 parent baa0efe commit 3895537
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 9 deletions.
12 changes: 12 additions & 0 deletions packages/core/src/oidc/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,18 @@ 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.IdentifierSingleSignOn })).toBe(
'identifier-single-sign-on'
);

// Legacy interactionMode support
expect(buildLoginPromptUrl({ interaction_mode: InteractionMode.SignUp })).toBe('register');
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/oidc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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';

export const getConstantClientMetadata = (
envSet: EnvSet,
Expand Down Expand Up @@ -86,6 +86,8 @@ export const getUtcStartOfTheDay = (date: Date) => {
);
};

// 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] ??
Expand All @@ -109,6 +111,13 @@ export const buildLoginPromptUrl = (params: ExtraParamsObject, appId?: unknown):
searchParams.append(ExtraParamsKey.LoginHint, params[ExtraParamsKey.LoginHint]);
}

if (EnvSet.values.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(':');
Expand Down
55 changes: 55 additions & 0 deletions packages/experience/src/hooks/use-identifier-params.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
33 changes: 25 additions & 8 deletions packages/experience/src/hooks/use-identifier-params.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
Expand Down
17 changes: 17 additions & 0 deletions packages/schemas/src/consts/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 or commas.
*
* 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. */
Expand All @@ -57,6 +69,9 @@ export enum InteractionMode {
export enum FirstScreen {
SignIn = 'signIn',
Register = 'register',
IdentifierSignIn = 'identifierSignIn',
IdentifierRegister = 'identifierRegister',
IdentifierSingleSignOn = 'identifierSingleSignOn',
}

export const extraParamsObjectGuard = z
Expand All @@ -66,6 +81,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>;

Expand All @@ -75,4 +91,5 @@ export type ExtraParamsObject = Partial<{
[ExtraParamsKey.DirectSignIn]: string;
[ExtraParamsKey.OrganizationId]: string;
[ExtraParamsKey.LoginHint]: string;
[ExtraParamsKey.Identifier]: string;
}>;
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down

0 comments on commit 3895537

Please sign in to comment.