Skip to content

Commit

Permalink
feat(experience): add identifier sso-only landing page
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun committed Aug 16, 2024
1 parent 6a809e1 commit 0449c73
Show file tree
Hide file tree
Showing 16 changed files with 319 additions and 106 deletions.
13 changes: 13 additions & 0 deletions packages/core/src/oidc/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,22 @@ describe('buildLoginPromptUrl', () => {
expect(
buildLoginPromptUrl({ first_screen: FirstScreen.SignIn, login_hint: '[email protected]' })
).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', () => {
Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/oidc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()}` : '');
Expand All @@ -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(':');
Expand Down
5 changes: 4 additions & 1 deletion packages/experience/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import SignIn from './pages/SignIn';
import SignInPassword from './pages/SignInPassword';
import SingleSignOnConnectors from './pages/SingleSignOnConnectors';
import SingleSignOnEmail from './pages/SingleSignOnEmail';
import SingleSignOnLanding from './pages/SingleSignOnLanding';
import SocialLanding from './pages/SocialLanding';
import SocialLinkAccount from './pages/SocialLinkAccount';
import SocialSignInWebCallback from './pages/SocialSignInWebCallback';
Expand Down Expand Up @@ -115,7 +116,9 @@ const App = () => {
</Route>

{/* Single sign-on */}
<Route path={experience.routes.sso} element={<LoadingLayerProvider />}>
<Route path={experience.routes.sso}>
{/* Single sign-on first screen landing page */}
{isDevFeaturesEnabled && <Route index element={<SingleSignOnLanding />} />}
<Route path="email" element={<SingleSignOnEmail />} />
<Route path="connectors" element={<SingleSignOnConnectors />} />
</Route>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ type Props = {
readonly authOptionsLink: TextLinkProps;
};

const IdentifierPageLayout = ({
/**
* FocusedAuthPageLayout Component
*
* This layout component is designed for focused authentication pages that serve as the first screen
* for specific auth methods, such as identifier sign-in, identifier-register, and single sign-on landing pages.
*/
const FocusedAuthPageLayout = ({
children,
pageMeta,
title,
Expand Down Expand Up @@ -52,4 +58,4 @@ const IdentifierPageLayout = ({
);
};

export default IdentifierPageLayout;
export default FocusedAuthPageLayout;
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
6 changes: 3 additions & 3 deletions packages/experience/src/pages/IdentifierRegister/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { AgreeToTermsPolicy, experience } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';

import IdentifierPageLayout from '@/Layout/IdentifierPageLayout';
import FocusedAuthPageLayout from '@/Layout/FocusedAuthPageLayout';
import { identifierInputDescriptionMap } from '@/utils/form';

import IdentifierRegisterForm from '../Register/IdentifierRegisterForm';
Expand All @@ -21,7 +21,7 @@ const IdentifierRegister = () => {
}

return (
<IdentifierPageLayout
<FocusedAuthPageLayout
pageMeta={{ titleKey: 'description.create_your_account' }}
title="description.create_account"
description={t('description.identifier_register_description', {
Expand All @@ -34,7 +34,7 @@ const IdentifierRegister = () => {
}}
>
<IdentifierRegisterForm signUpMethods={signUpMethods} />
</IdentifierPageLayout>
</FocusedAuthPageLayout>
);
};

Expand Down
6 changes: 3 additions & 3 deletions packages/experience/src/pages/IdentifierSignIn/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';

import IdentifierPageLayout from '@/Layout/IdentifierPageLayout';
import FocusedAuthPageLayout from '@/Layout/FocusedAuthPageLayout';
import { identifierInputDescriptionMap } from '@/utils/form';

import IdentifierSignInForm from '../SignIn/IdentifierSignInForm';
Expand All @@ -29,7 +29,7 @@ const IdentifierSignIn = () => {
}

return (
<IdentifierPageLayout
<FocusedAuthPageLayout
pageMeta={{ titleKey: 'description.sign_in' }}
title="description.sign_in"
description={t('description.identifier_sign_in_description', {
Expand All @@ -49,7 +49,7 @@ const IdentifierSignIn = () => {
) : (
<IdentifierSignInForm signInMethods={signInMethods} />
)}
</IdentifierPageLayout>
</FocusedAuthPageLayout>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@
margin-left: _.unit(0.5);
margin-top: _.unit(-3);
}

.terms {
margin-bottom: _.unit(4);
}
}
Loading

0 comments on commit 0449c73

Please sign in to comment.