0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

refactor(ui): refactor SIE schema (#2264)

This commit is contained in:
simeng-li 2022-10-27 15:55:38 +08:00 committed by GitHub
parent 95cf53b59a
commit 3e6021ad16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 155 additions and 1027 deletions

View file

@ -161,6 +161,27 @@ export const mockSocialConnectorData = {
configTemplate: '',
};
export const emailSignInMethod = {
identifier: SignInIdentifier.Email,
password: true,
verificationCode: true,
isPasswordPrimary: true,
};
export const smsSignInMethod = {
identifier: SignInIdentifier.Sms,
password: true,
verificationCode: true,
isPasswordPrimary: true,
};
export const usernameSignInMethod = {
identifier: SignInIdentifier.Username,
password: true,
verificationCode: false,
isPasswordPrimary: true,
};
export const mockSignInExperience: SignInExperience = {
id: 'foo',
color: {
@ -184,29 +205,10 @@ export const mockSignInExperience: SignInExperience = {
signUp: {
identifier: SignUpIdentifier.Username,
password: true,
verify: false,
verify: true,
},
signIn: {
methods: [
{
identifier: SignInIdentifier.Username,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
{
identifier: SignInIdentifier.Sms,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
methods: [usernameSignInMethod, emailSignInMethod, smsSignInMethod],
},
signInMethods: {
username: SignInMethodState.Primary,
@ -220,12 +222,17 @@ export const mockSignInExperience: SignInExperience = {
};
export const mockSignInExperienceSettings: SignInExperienceSettings = {
id: mockSignInExperience.id,
color: mockSignInExperience.color,
branding: mockSignInExperience.branding,
termsOfUse: mockSignInExperience.termsOfUse,
languageInfo: mockSignInExperience.languageInfo,
primarySignInMethod: 'username',
secondarySignInMethods: ['email', 'sms', 'social'],
signIn: mockSignInExperience.signIn,
signUp: {
methods: [SignInIdentifier.Username],
password: true,
verify: true,
},
socialConnectors,
signInMode: SignInMode.SignInAndRegister,
forgotPassword: false,

View file

@ -3,10 +3,11 @@
* The API will be deprecated in the future once SSR is implemented.
*/
import type { SignInExperience } from '@logto/schemas';
import ky from 'ky';
export const getSignInExperience = async <T extends SignInExperience>(): Promise<T> => {
import type { SignInExperienceResponse } from '@/types';
export const getSignInExperience = async <T extends SignInExperienceResponse>(): Promise<T> => {
return ky.get('/api/.well-known/sign-in-exp').json<T>();
};

View file

@ -1,22 +0,0 @@
@use '@/scss/underscore' as _;
.socialButton {
border-radius: 50%;
@include _.flex-column;
background: var(--color-bg-layer-2);
border: none;
width: 40px;
height: 40px;
cursor: pointer;
}
.inverse {
background: var(--color-type-primary);
}
.icon {
@include _.image-align-center;
width: 24px;
height: 24px;
overflow: hidden;
}

View file

@ -1,27 +0,0 @@
import classNames from 'classnames';
import { isAppleConnector } from '@/utils/social-connectors';
import * as styles from './SocialIconButton.module.scss';
type Props = {
className?: string;
logo: string;
target?: string;
onClick?: () => void;
};
const SocialIconButton = ({ className, logo, target, onClick }: Props) => (
<button
className={classNames(
styles.socialButton,
isAppleConnector(target) && styles.inverse,
className
)}
onClick={onClick}
>
{logo && <img src={logo} alt={target} className={styles.icon} />}
</button>
);
export default SocialIconButton;

View file

@ -1,59 +0,0 @@
@use '@/scss/underscore' as _;
.drawer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
outline: none;
padding-bottom: env(safe-area-inset-bottom);
}
.container {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
padding: _.unit(5) _.unit(5) 0;
background: var(--color-bg-body-overlay);
max-height: 411px;
@include _.flex-column(stretch, normal);
}
.header {
@include _.flex-row;
justify-content: flex-end;
align-items: center;
margin-bottom: _.unit(4);
svg {
color: var(--color-type-secondary);
width: 20px;
height: 20px;
}
}
.content {
flex: 1;
overflow-y: auto;
padding-bottom: _.unit(5);
&::-webkit-scrollbar {
display: none;
}
}
/* stylelint-disable selector-class-pattern */
:global {
.ReactModal__Content[role='popup'] {
transform: translateY(100%);
transition: transform 0.3s ease-in-out;
}
.ReactModal__Content--after-open[role='popup'] {
transform: translateY(0);
}
.ReactModal__Content--before-close[role='popup'] {
transform: translateY(100%);
}
}
/* stylelint-enable selector-class-pattern */

View file

@ -1,15 +0,0 @@
import { render } from '@testing-library/react';
import Drawer from '.';
describe('Drawer', () => {
it('render children', () => {
const { queryByText } = render(
<Drawer isOpen onClose={jest.fn()}>
children
</Drawer>
);
expect(queryByText('children')).not.toBeNull();
});
});

View file

@ -1,43 +0,0 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import ReactModal from 'react-modal';
import CloseIcon from '@/assets/icons/close-icon.svg';
import * as modalStyles from '../../scss/modal.module.scss';
import IconButton from '../Button/IconButton';
import * as styles from './index.module.scss';
type Props = {
className?: string;
isOpen?: boolean;
children: ReactNode;
onClose: () => void;
};
const Drawer = ({ className, isOpen = false, children, onClose }: Props) => {
return (
<ReactModal
shouldCloseOnOverlayClick
// For styling use
// eslint-disable-next-line jsx-a11y/aria-role
role="popup"
isOpen={isOpen}
className={classNames(styles.drawer, className)}
overlayClassName={modalStyles.overlay}
closeTimeoutMS={300}
onRequestClose={onClose}
>
<div className={styles.container}>
<div className={styles.header}>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</div>
<div className={styles.content}>{children}</div>
</div>
</ReactModal>
);
};
export default Drawer;

View file

@ -1,16 +0,0 @@
@use '@/scss/underscore' as _;
.item {
padding: _.unit(1.5) _.unit(2);
border-radius: var(--radius);
list-style: none;
font: var(--font-body-2);
color: var(--color-type-primary);
cursor: pointer;
@include _.flex-row;
overflow: hidden;
&:hover {
background: var(--color-overlay-neutral-hover);
}
}

View file

@ -1,25 +0,0 @@
import classNames from 'classnames';
import { onKeyDownHandler } from '@/utils/a11y';
import * as styles from './DropdownItem.module.scss';
type Props = {
onClick?: () => void;
className?: string;
children: React.ReactNode;
};
const DropdownItem = ({ onClick, className, children }: Props) => (
<li
role="menuitem"
tabIndex={0}
className={classNames(styles.item, className)}
onKeyDown={onKeyDownHandler(onClick)}
onClick={onClick}
>
{children}
</li>
);
export default DropdownItem;

View file

@ -1,23 +0,0 @@
@use '@/scss/underscore' as _;
.content {
background: var(--color-bg-float-overlay);
box-shadow: var(--color-shadow);
border-radius: var(--radius);
border: _.border(var(--color-line-divider));
&:focus {
outline: none;
}
}
.overlay {
background: transparent;
position: fixed;
inset: 0;
}
.list {
margin: 0;
padding: _.unit(1.5) _.unit(1);
}

View file

@ -1,42 +0,0 @@
import classNames from 'classnames';
import type { Props as ModalProps } from 'react-modal';
import ReactModal from 'react-modal';
import { onKeyDownHandler } from '@/utils/a11y';
import * as styles from './index.module.scss';
export { default as DropdownItem } from './DropdownItem';
type Props = ModalProps & {
onClose?: () => void;
};
const Dropdown = ({ onClose, children, className, ...rest }: Props) => {
return (
<ReactModal
shouldCloseOnOverlayClick
className={classNames(styles.content, className)}
overlayClassName={styles.overlay}
ariaHideApp={false}
onRequestClose={onClose}
{...rest}
>
<ul
role="menu"
tabIndex={0}
className={styles.list}
onKeyDown={onKeyDownHandler({
Esc: onClose,
Enter: onClose,
' ': onClose,
})}
onClick={onClose}
>
{children}
</ul>
</ReactModal>
);
};
export default Dropdown;

View file

@ -2,8 +2,6 @@ import { fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import PasswordlessSwitch from './PasswordlessSwitch';
@ -22,9 +20,7 @@ describe('<PasswordlessSwitch />', () => {
test('render sms passwordless switch', () => {
const { queryByText, getByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/forgot-password/sms']}>
<SettingsProvider>
<PasswordlessSwitch target="email" />
</SettingsProvider>
<PasswordlessSwitch target="email" />
</MemoryRouter>
);
@ -42,9 +38,7 @@ describe('<PasswordlessSwitch />', () => {
test('render email passwordless switch', () => {
const { queryByText, getByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/forgot-password/email']}>
<SettingsProvider>
<PasswordlessSwitch target="sms" />
</SettingsProvider>
<PasswordlessSwitch target="sms" />
</MemoryRouter>
);
@ -55,22 +49,4 @@ describe('<PasswordlessSwitch />', () => {
expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/sms' }, { replace: true });
});
test('should not render the switch if SIE setting does not has the supported sign in method', () => {
const { queryByText, getByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/forgot-password/email']}>
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
primarySignInMethod: 'username',
secondarySignInMethods: ['email', 'social'],
}}
>
<PasswordlessSwitch target="sms" />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('action.switch_to')).toBeNull();
});
});

View file

@ -1,9 +1,7 @@
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import TextLink from '@/components/TextLink';
import { PageContext } from '@/hooks/use-page-context';
type Props = {
target: 'sms' | 'email';
@ -12,21 +10,9 @@ type Props = {
const PasswordlessSwitch = ({ target, className }: Props) => {
const { t } = useTranslation();
const { experienceSettings } = useContext(PageContext);
const { pathname } = useLocation();
const navigate = useNavigate();
if (!experienceSettings) {
return null;
}
if (
experienceSettings.primarySignInMethod !== target &&
!experienceSettings.secondarySignInMethods.includes(target)
) {
return null;
}
const targetPathname = pathname.replace(target === 'email' ? 'sms' : 'email', target);
return (

View file

@ -1,127 +0,0 @@
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { socialConnectors, mockSignInExperienceSettings } from '@/__mocks__/logto';
import * as socialSignInApi from '@/apis/social';
import SecondarySocialSignIn, { defaultSize } from '.';
describe('SecondarySocialSignIn', () => {
const mockOrigin = 'https://logto.dev';
const invokeSocialSignInSpy = jest
.spyOn(socialSignInApi, 'invokeSocialSignIn')
.mockResolvedValue({ redirectTo: `${mockOrigin}/callback` });
beforeEach(() => {
/* eslint-disable @silverhand/fp/no-mutation */
// @ts-expect-error mock global object
globalThis.logtoNativeSdk = {
platform: 'web',
getPostMessage: jest.fn(() => jest.fn()),
callbackLink: '/logto:',
supportedConnector: {
universal: true,
nativeTargets: socialConnectors.map(({ target }) => target),
},
};
/* eslint-enable @silverhand/fp/no-mutation */
});
afterEach(() => {
jest.clearAllMocks();
});
it('less than four connectors', () => {
const { container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
socialConnectors: socialConnectors.slice(0, defaultSize - 1),
}}
>
<MemoryRouter>
<SecondarySocialSignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelectorAll('button')).toHaveLength(defaultSize - 1);
});
it('more than four connectors', () => {
const { container } = renderWithPageContext(
<SettingsProvider>
<MemoryRouter>
<SecondarySocialSignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelectorAll('button')).toHaveLength(defaultSize); // Plus Expand button
expect(container.querySelector('svg')).not.toBeNull();
});
it('invoke web social signIn', async () => {
const connectors = socialConnectors.slice(0, 1);
const { container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
termsOfUse: { enabled: false },
socialConnectors: connectors,
}}
>
<MemoryRouter>
<SecondarySocialSignIn />
</MemoryRouter>
</SettingsProvider>
);
const socialButton = container.querySelector('button');
if (socialButton) {
act(() => {
fireEvent.click(socialButton);
});
void waitFor(() => {
expect(invokeSocialSignInSpy).toBeCalled();
});
}
});
it('invoke native social signIn', async () => {
/* eslint-disable @silverhand/fp/no-mutation */
// @ts-expect-error mock global object
logtoNativeSdk.platform = 'ios';
/* eslint-enable @silverhand/fp/no-mutation */
const connectors = socialConnectors.slice(0, 1);
const { container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
termsOfUse: { enabled: false },
socialConnectors: connectors,
}}
>
<MemoryRouter>
<SecondarySocialSignIn />
</MemoryRouter>
</SettingsProvider>
);
const socialButton = container.querySelector('button');
if (socialButton) {
act(() => {
fireEvent.click(socialButton);
});
void waitFor(() => {
expect(invokeSocialSignInSpy).toBeCalled();
expect(logtoNativeSdk?.getPostMessage).toBeCalled();
});
}
});
});

View file

@ -1,69 +0,0 @@
import { useMemo, useState, useRef } from 'react';
import useNativeMessageListener from '@/hooks/use-native-message-listener';
import usePlatform from '@/hooks/use-platform';
import useSocial from '@/hooks/use-social';
import SocialSignInDropdown from '../SocialSignInDropdown';
import SocialSignInIconList from '../SocialSignInIconList';
import SocialSignInPopUp from '../SocialSignInPopUp';
export const defaultSize = 4;
type Props = {
className?: string;
};
const SecondarySocialSignIn = ({ className }: Props) => {
const { socialConnectors } = useSocial();
const [showModal, setShowModal] = useState(false);
const moreButtonRef = useRef<HTMLButtonElement>(null);
const { isMobile } = usePlatform();
useNativeMessageListener();
const isCollapsed = socialConnectors.length > defaultSize;
const displayConnectors = useMemo(() => {
if (isCollapsed) {
return socialConnectors.slice(0, defaultSize - 1);
}
return socialConnectors;
}, [socialConnectors, isCollapsed]);
return (
<>
<SocialSignInIconList
className={className}
connectors={displayConnectors}
hasMore={isCollapsed}
moreButtonRef={moreButtonRef}
onMoreButtonClick={() => {
setShowModal(true);
}}
/>
{isCollapsed && isMobile && (
<SocialSignInPopUp
connectors={socialConnectors.slice(defaultSize - 1)}
isOpen={showModal}
onClose={() => {
setShowModal(false);
}}
/>
)}
{isCollapsed && !isMobile && (
<SocialSignInDropdown
anchorRef={moreButtonRef}
isOpen={showModal}
connectors={socialConnectors.slice(defaultSize - 1)}
onClose={() => {
setShowModal(false);
}}
/>
)}
</>
);
};
export default SecondarySocialSignIn;

View file

@ -1,32 +0,0 @@
@use '@/scss/underscore' as _;
.socialDropDown {
position: absolute;
min-width: 208px;
transform: translateY(-100%) scale(0);
transform-origin: 12px bottom;
opacity: 0%;
transition: transform 0.1s, opacity 0.1s;
}
.socialLogo {
width: 24px;
height: 24px;
margin-right: _.unit(4);
}
/* stylelint-disable selector-class-pattern */
:global(.ReactModal__Content--after-open) {
&.socialDropDown {
transform: translateY(-100%) scale(1);
opacity: 100%;
}
}
:global(.ReactModal__Content--before-close) {
&.socialDropDown {
transform: translateY(-100%) scale(0);
opacity: 0%;
}
}
/* stylelint-enable selector-class-pattern */

View file

@ -1,60 +0,0 @@
import type { ConnectorMetadata } from '@logto/schemas';
import { fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { socialConnectors } from '@/__mocks__/logto';
import SocialSignInDropdown from '.';
const mockInvokeSocialSignIn = jest.fn();
// eslint-disable-next-line unicorn/consistent-function-scoping
jest.mock('@/hooks/use-social', () => () => ({
invokeSocialSignIn: (connector: ConnectorMetadata) => {
mockInvokeSocialSignIn(connector);
},
}));
describe('SocialSignInDropdown', () => {
it('render properly', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<MemoryRouter>
<SocialSignInDropdown
isOpen
connectors={socialConnectors}
anchorRef={{ current: document.createElement('div') }}
onClose={jest.fn}
/>
</MemoryRouter>
</SettingsProvider>
);
for (const { name } of socialConnectors) {
expect(queryByText(name.en)).not.toBeNull();
}
});
it('invokeSignIn', () => {
const { getByText } = renderWithPageContext(
<SettingsProvider>
<MemoryRouter>
<SocialSignInDropdown
isOpen
connectors={socialConnectors}
anchorRef={{ current: document.createElement('div') }}
onClose={jest.fn}
/>
</MemoryRouter>
</SettingsProvider>
);
if (socialConnectors[0]?.name.en) {
const socialLink = getByText(socialConnectors[0]?.name.en);
fireEvent.click(socialLink);
expect(mockInvokeSocialSignIn).toBeCalledWith(socialConnectors[0]);
}
});
});

View file

@ -1,74 +0,0 @@
import { isLanguageTag } from '@logto/language-kit';
import type { ConnectorMetadata } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Dropdown, { DropdownItem } from '@/components/Dropdown';
import useSocial from '@/hooks/use-social';
import * as styles from './index.module.scss';
type Props = {
anchorRef?: React.RefObject<HTMLElement>;
isOpen: boolean;
onClose: () => void;
connectors: ConnectorMetadata[];
};
const SocialSignInDropdown = ({ isOpen, onClose, connectors, anchorRef }: Props) => {
const {
i18n: { language },
} = useTranslation();
const [contentStyle, setContentStyle] = useState<{ top?: number; left?: number }>();
const { invokeSocialSignIn, theme } = useSocial();
const adjustPosition = useCallback(() => {
if (anchorRef?.current) {
const { left, top } = anchorRef.current.getBoundingClientRect();
setContentStyle({
left,
top: top - 8,
});
}
}, [anchorRef]);
return (
<Dropdown
isOpen={isOpen}
className={styles.socialDropDown}
style={{ content: contentStyle }}
closeTimeoutMS={100}
onClose={onClose}
onAfterOpen={adjustPosition}
onAfterClose={() => {
setContentStyle(undefined);
}}
>
{connectors.map((connector) => {
const { id, name, logo, logoDark } = connector;
const localName = conditional(isLanguageTag(language) && name[language]) ?? name.en;
return (
<DropdownItem
key={id}
onClick={() => {
void invokeSocialSignIn(connector);
onClose();
}}
>
<img
src={theme === 'dark' ? logoDark ?? logo : logo}
alt={id}
className={styles.socialLogo}
/>
<span>{localName}</span>
</DropdownItem>
);
})}
</Dropdown>
);
};
export default SocialSignInDropdown;

View file

@ -1,26 +0,0 @@
@use '@/scss/underscore' as _;
.socialIconList {
@include _.flex-row;
justify-content: center;
}
.socialButton {
margin-right: _.unit(8);
&:last-child {
margin-right: 0;
}
}
.moreButton {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-bg-layer-2);
> svg {
width: 24px;
height: 24px;
}
}

View file

@ -1,56 +0,0 @@
import type { ConnectorMetadata } from '@logto/schemas';
import classNames from 'classnames';
import MoreSocialIcon from '@/assets/icons/more-social-icon.svg';
import IconButton from '@/components/Button/IconButton';
import SocialIconButton from '@/components/Button/SocialIconButton';
import useSocial from '@/hooks/use-social';
import { getLogoUrl } from '@/utils/logo';
import { isAppleConnector } from '@/utils/social-connectors';
import * as styles from './index.module.scss';
type Props = {
className?: string;
connectors?: ConnectorMetadata[];
hasMore?: boolean;
moreButtonRef: React.RefObject<HTMLButtonElement>;
onMoreButtonClick?: () => void;
};
const SocialSignInIconList = ({
className,
connectors = [],
hasMore = false,
moreButtonRef,
onMoreButtonClick,
}: Props) => {
const { invokeSocialSignIn, theme } = useSocial();
return (
<div className={classNames(styles.socialIconList, className)}>
{connectors.map((connector) => {
const { id, target, logo: logoUrl, logoDark: darkLogoUrl } = connector;
return (
<SocialIconButton
key={id}
className={styles.socialButton}
logo={getLogoUrl({ theme, logoUrl, darkLogoUrl, isApple: isAppleConnector() })}
target={target}
onClick={() => {
void invokeSocialSignIn(connector);
}}
/>
);
})}
{hasMore && (
<IconButton ref={moreButtonRef} className={styles.moreButton} onClick={onMoreButtonClick}>
<MoreSocialIcon />
</IconButton>
)}
</div>
);
};
export default SocialSignInIconList;

View file

@ -1,24 +0,0 @@
import type { ConnectorMetadata } from '@logto/schemas';
import Drawer from '@/components/Drawer';
import SocialSignInList from '../SocialSignInList';
type Props = {
connectors?: ConnectorMetadata[];
className?: string;
isOpen?: boolean;
onClose: () => void;
};
const SocialSignInPopUp = ({ connectors = [], isOpen = false, onClose, className }: Props) => (
<Drawer className={className} isOpen={isOpen} onClose={onClose}>
<SocialSignInList
isCollapseEnabled={false}
socialConnectors={connectors}
onSocialSignInCallback={onClose}
/>
</Drawer>
);
export default SocialSignInPopUp;

View file

@ -1,2 +0,0 @@
export { default as SecondarySocialSignIn } from './SecondarySocialSignIn';
export { default as PrimarySocialSignIn } from './PrimarySocialSignIn';

View file

@ -2,7 +2,7 @@ import TermsOfUse from '@/containers/TermsOfUse';
import useNativeMessageListener from '@/hooks/use-native-message-listener';
import useSocial from '@/hooks/use-social';
import SocialSignInList from '../SocialSignInList';
import SocialSignInList from './SocialSignInList';
import * as styles from './index.module.scss';
export const defaultSize = 3;
@ -11,7 +11,7 @@ type Props = {
className?: string;
};
const PrimarySocialSignIn = ({ className }: Props) => {
const SocialSignIn = ({ className }: Props) => {
const { socialConnectors } = useSocial();
useNativeMessageListener();
@ -23,4 +23,4 @@ const PrimarySocialSignIn = ({ className }: Props) => {
);
};
export default PrimarySocialSignIn;
export default SocialSignIn;

View file

@ -6,7 +6,6 @@ import { is } from 'superstruct';
import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';
import useApi from '@/hooks/use-api';
import { PageContext } from '@/hooks/use-page-context';
import type { LocalSignInMethod } from '@/types';
import { bindSocialStateGuard } from '@/types/guard';
const useBindSocial = () => {
@ -29,13 +28,11 @@ const useBindSocial = () => {
[asyncBindSocialRelatedUser]
);
// TODO: @simeng LOG-4487
const localSignInMethods = useMemo(() => {
const primaryMethod = experienceSettings?.primarySignInMethod;
const secondaryMethods = experienceSettings?.secondarySignInMethods ?? [];
const signInMethods = experienceSettings?.signIn.methods ?? [];
return [primaryMethod, ...secondaryMethods].filter(
(method): method is LocalSignInMethod => Boolean(method) && method !== 'social'
);
return signInMethods.map(({ identifier }) => identifier);
}, [experienceSettings]);
useEffect(() => {

View file

@ -8,7 +8,7 @@ import initI18n from '@/i18n/init';
import { changeLanguage } from '@/i18n/utils';
import type { SignInExperienceSettings, PreviewConfig } from '@/types';
import { parseQueryParameters } from '@/utils';
import { getPrimarySignInMethod, getSecondarySignInMethods } from '@/utils/sign-in-experience';
import { signUpIdentifierMap } from '@/utils/sign-in-experience';
import { filterPreviewSocialConnectors } from '@/utils/social-connectors';
const usePreview = (context: Context): [boolean, PreviewConfig?] => {
@ -54,21 +54,25 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
}
const {
signInExperience: { signInMethods, socialConnectors, color, ...rest },
signInExperience: { signUp, socialConnectors, color, ...rest },
language,
mode,
platform,
isNative,
} = previewConfig;
const { identifier, ...signUpSettings } = signUp;
const experienceSettings: SignInExperienceSettings = {
...rest,
color: {
...color,
isDarkModeEnabled: false, // Disable theme mode auto detection on preview
},
primarySignInMethod: getPrimarySignInMethod(signInMethods),
secondarySignInMethods: getSecondarySignInMethods(signInMethods),
signUp: {
methods: signUpIdentifierMap[identifier],
...signUpSettings,
},
socialConnectors: filterPreviewSocialConnectors(
isNative ? ConnectorPlatform.Native : ConnectorPlatform.Web,
socialConnectors

View file

@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
@ -16,22 +15,13 @@ const ForgotPassword = () => {
const { t } = useTranslation();
const { method = '' } = useParams<Props>();
const forgotPasswordForm = useMemo(() => {
if (method === 'sms') {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <PhonePasswordless autoFocus hasSwitch type="forgot-password" hasTerms={false} />;
}
if (method === 'email') {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <EmailPasswordless autoFocus hasSwitch type="forgot-password" hasTerms={false} />;
}
}, [method]);
// TODO: @simeng LOG-4486 apply supported method guard validation. Including the form hasSwitch validation bellow
if (!['email', 'sms'].includes(method)) {
return <ErrorPage />;
}
const PasswordlessForm = method === 'email' ? EmailPasswordless : PhonePasswordless;
return (
<div className={styles.wrapper}>
<NavBar />
@ -40,7 +30,8 @@ const ForgotPassword = () => {
<div className={styles.description}>
{t(`description.reset_password_description_${method === 'email' ? 'email' : 'sms'}`)}
</div>
{forgotPasswordForm}
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<PasswordlessForm autoFocus hasSwitch type="forgot-password" hasTerms={false} />
</div>
</div>
);

View file

@ -0,0 +1,38 @@
import { useContext } from 'react';
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameSignIn from '@/containers/UsernameSignIn';
import { PageContext } from '@/hooks/use-page-context';
import * as styles from './index.module.scss';
const MainForm = () => {
const { experienceSettings } = useContext(PageContext);
if (!experienceSettings) {
return null;
}
const { signIn, socialConnectors } = experienceSettings;
const primarySignInMethod = signIn.methods[0];
switch (primarySignInMethod?.identifier) {
case 'email':
return <EmailPasswordless type="sign-in" className={styles.primarySignIn} />;
case 'sms':
return <PhonePasswordless type="sign-in" className={styles.primarySignIn} />;
case 'username':
return <UsernameSignIn className={styles.primarySignIn} />;
default: {
if (socialConnectors.length > 0) {
return <SocialSignIn className={styles.primarySocial} />;
}
return null;
}
}
};
export default MainForm;

View file

@ -2,7 +2,11 @@ import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import {
mockSignInExperienceSettings,
emailSignInMethod,
smsSignInMethod,
} from '@/__mocks__/logto';
import { defaultSize } from '@/containers/SocialSignIn/SocialSignInList';
import SignIn from '@/pages/SignIn';
@ -26,7 +30,10 @@ describe('<SignIn />', () => {
test('renders with email as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{ ...mockSignInExperienceSettings, primarySignInMethod: 'email' }}
settings={{
...mockSignInExperienceSettings,
signIn: { methods: [emailSignInMethod] },
}}
>
<MemoryRouter>
<SignIn />
@ -39,7 +46,9 @@ describe('<SignIn />', () => {
test('renders with sms as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider settings={{ ...mockSignInExperienceSettings, primarySignInMethod: 'sms' }}>
<SettingsProvider
settings={{ ...mockSignInExperienceSettings, signIn: { methods: [smsSignInMethod] } }}
>
<MemoryRouter>
<SignIn />
</MemoryRouter>
@ -51,9 +60,7 @@ describe('<SignIn />', () => {
test('renders with social as primary', async () => {
const { container } = renderWithPageContext(
<SettingsProvider
settings={{ ...mockSignInExperienceSettings, primarySignInMethod: 'social' }}
>
<SettingsProvider settings={{ ...mockSignInExperienceSettings, signIn: { methods: [] } }}>
<MemoryRouter>
<SignIn />
</MemoryRouter>

View file

@ -1,4 +1,4 @@
import { BrandingStyle, SignInMode } from '@logto/schemas';
import { BrandingStyle } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
@ -7,8 +7,8 @@ import AppNotification from '@/containers/AppNotification';
import { PageContext } from '@/hooks/use-page-context';
import { getLogoUrl } from '@/utils/logo';
import MainForm from './MainForm';
import * as styles from './index.module.scss';
import { PrimarySection, SecondarySection, CreateAccountLink } from './registry';
const SignIn = () => {
const { experienceSettings, theme, platform } = useContext(PageContext);
@ -28,24 +28,7 @@ const SignIn = () => {
headline={style === BrandingStyle.Logo_Slogan ? slogan : undefined}
logo={getLogoUrl({ theme, logoUrl, darkLogoUrl })}
/>
<PrimarySection
signInMethod={experienceSettings.primarySignInMethod}
socialConnectors={experienceSettings.socialConnectors}
signInMode={experienceSettings.signInMode}
/>
{experienceSettings.signInMode !== SignInMode.Register && (
<SecondarySection
primarySignInMethod={experienceSettings.primarySignInMethod}
secondarySignInMethods={experienceSettings.secondarySignInMethods}
socialConnectors={experienceSettings.socialConnectors}
/>
)}
{experienceSettings.signInMode === SignInMode.SignInAndRegister && (
<CreateAccountLink primarySignInMethod={experienceSettings.primarySignInMethod} />
)}
<MainForm />
<AppNotification />
</div>
{platform === 'web' && <div className={styles.placeholderBottom} />}

View file

@ -1,121 +0,0 @@
import type { ConnectorMetadata } from '@logto/schemas';
import { SignInMode } from '@logto/schemas';
import Divider from '@/components/Divider';
import TextLink from '@/components/TextLink';
import CreateAccount from '@/containers/CreateAccount';
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
import SignInMethodsLink from '@/containers/SignInMethodsLink';
import { PrimarySocialSignIn, SecondarySocialSignIn } from '@/containers/SocialSignIn';
import UsernameSignIn from '@/containers/UsernameSignIn';
import type { SignInMethod, LocalSignInMethod } from '@/types';
import * as styles from './index.module.scss';
export const PrimarySection = ({
signInMethod,
socialConnectors = [],
signInMode,
}: {
signInMethod?: SignInMethod;
socialConnectors?: ConnectorMetadata[];
signInMode?: SignInMode;
}) => {
switch (signInMethod) {
case 'email':
return (
<EmailPasswordless
type={signInMode === SignInMode.Register ? 'register' : 'sign-in'}
className={styles.primarySignIn}
/>
);
case 'sms':
return (
<PhonePasswordless
type={signInMode === SignInMode.Register ? 'register' : 'sign-in'}
className={styles.primarySignIn}
/>
);
case 'username':
return signInMode === SignInMode.Register ? (
<CreateAccount />
) : (
<UsernameSignIn className={styles.primarySignIn} />
);
case 'social':
return socialConnectors.length > 0 ? (
<PrimarySocialSignIn className={styles.primarySocial} />
) : null;
default:
return null;
}
};
export const SecondarySection = ({
primarySignInMethod,
secondarySignInMethods,
socialConnectors = [],
}: {
primarySignInMethod?: SignInMethod;
secondarySignInMethods?: SignInMethod[];
socialConnectors?: ConnectorMetadata[];
}) => {
if (!primarySignInMethod || !secondarySignInMethods?.length) {
return null;
}
const localMethods = secondarySignInMethods.filter(
(method): method is LocalSignInMethod => method !== 'social'
);
if (primarySignInMethod === 'social' && localMethods.length > 0) {
return (
<>
{socialConnectors.length > 0 && (
<Divider label="description.continue_with" className={styles.divider} />
)}
<SignInMethodsLink signInMethods={localMethods} />
</>
);
}
return (
<>
<SignInMethodsLink
signInMethods={localMethods}
template="sign_in_with"
className={styles.otherMethodsLink}
/>
{secondarySignInMethods.includes('social') && socialConnectors.length > 0 && (
<>
<Divider label="description.or" className={styles.divider} />
<SecondarySocialSignIn />
</>
)}
</>
);
};
export const CreateAccountLink = ({
primarySignInMethod,
}: {
primarySignInMethod?: SignInMethod;
}) => {
switch (primarySignInMethod) {
case 'username':
case 'email':
case 'sms':
return (
<>
<div className={styles.placeHolder} />
<TextLink
className={styles.createAccount}
to={`/register/${primarySignInMethod}`}
text="action.create_account"
/>
</>
);
default:
return null;
}
};

View file

@ -1,3 +1,4 @@
import { SignInIdentifier } from '@logto/schemas';
import * as s from 'superstruct';
export const bindSocialStateGuard = s.object({
@ -9,7 +10,10 @@ export const passcodeStateGuard = s.object({
sms: s.optional(s.string()),
});
export const passcodeMethodGuard = s.union([s.literal('email'), s.literal('sms')]);
export const passcodeMethodGuard = s.union([
s.literal(SignInIdentifier.Email),
s.literal(SignInIdentifier.Sms),
]);
export const userFlowGuard = s.union([
s.literal('sign-in'),

View file

@ -1,4 +1,9 @@
import type { SignInExperience, ConnectorMetadata, AppearanceMode } from '@logto/schemas';
import type {
SignInExperience,
ConnectorMetadata,
AppearanceMode,
SignInIdentifier,
} from '@logto/schemas';
export type UserFlow = 'sign-in' | 'register' | 'forgot-password';
export type SignInMethod = 'username' | 'email' | 'sms' | 'social';
@ -15,18 +20,20 @@ export type Platform = 'web' | 'mobile';
// TODO: @simeng, @sijie, @charles should we combine this with admin console?
export type Theme = 'dark' | 'light';
export type SignInExperienceSettingsResponse = SignInExperience & {
// Omit signInMethods property since it is deprecated,
// Omit socialSignInConnectorTargets since it is being translated into socialConnectors
export type SignInExperienceResponse = Omit<
SignInExperience,
'signInMethods' | 'socialSignInConnectorTargets'
> & {
socialConnectors: ConnectorMetadata[];
notification?: string;
};
// FIXME @simeng
export type SignInExperienceSettings = Omit<
SignInExperienceSettingsResponse,
'id' | 'signInMethods' | 'socialSignInConnectorTargets' | 'signIn' | 'signUp'
> & {
primarySignInMethod: SignInMethod;
secondarySignInMethods: SignInMethod[];
export type SignInExperienceSettings = Omit<SignInExperienceResponse, 'signUp'> & {
signUp: Omit<SignInExperienceResponse['signUp'], 'identifier'> & {
methods: SignInIdentifier[];
};
};
export enum ConfirmModalMessage {
@ -34,7 +41,7 @@ export enum ConfirmModalMessage {
}
export type PreviewConfig = {
signInExperience: SignInExperienceSettingsResponse;
signInExperience: SignInExperienceResponse;
language: string;
mode: AppearanceMode.LightMode | AppearanceMode.DarkMode;
platform: Platform;

View file

@ -1,5 +1,4 @@
/* istanbul ignore file */
// FIXME: @simeng-li
import type { KeyboardEventHandler, KeyboardEvent } from 'react';

View file

@ -17,9 +17,7 @@ describe('getSignInExperienceSettings', () => {
expect(settings.branding).toEqual(mockSignInExperience.branding);
expect(settings.languageInfo).toEqual(mockSignInExperience.languageInfo);
expect(settings.termsOfUse).toEqual(mockSignInExperience.termsOfUse);
expect(settings.primarySignInMethod).toEqual('username');
expect(settings.secondarySignInMethods).toContain('email');
expect(settings.secondarySignInMethods).toContain('sms');
expect(settings.secondarySignInMethods).toContain('social');
expect(settings.signUp.methods).toContain('username');
expect(settings.signIn.methods).toHaveLength(3);
});
});

View file

@ -3,45 +3,38 @@
* Remove this once we have a better way to get the sign in experience through SSR
*/
import type { SignInMethods } from '@logto/schemas';
import { SignInIdentifier, SignUpIdentifier } from '@logto/schemas';
import { getSignInExperience } from '@/apis/settings';
import type {
SignInMethod,
SignInExperienceSettingsResponse,
SignInExperienceSettings,
} from '@/types';
import type { SignInExperienceSettings, SignInExperienceResponse } from '@/types';
import { filterSocialConnectors } from '@/utils/social-connectors';
import { entries } from '.';
export const getPrimarySignInMethod = (signInMethods: SignInMethods) => {
for (const [key, value] of entries(signInMethods)) {
if (value === 'primary') {
return key;
}
}
return 'username';
export const signUpIdentifierMap: Record<SignUpIdentifier, SignInIdentifier[]> = {
[SignUpIdentifier.Username]: [SignInIdentifier.Username],
[SignUpIdentifier.Email]: [SignInIdentifier.Email],
[SignUpIdentifier.Sms]: [SignInIdentifier.Sms],
[SignUpIdentifier.EmailOrSms]: [SignInIdentifier.Email, SignInIdentifier.Sms],
[SignUpIdentifier.None]: [],
};
export const getSecondarySignInMethods = (signInMethods: SignInMethods) =>
entries(signInMethods).reduce<SignInMethod[]>((methods, [key, value]) => {
if (value === 'secondary') {
return [...methods, key];
}
return methods;
}, []);
export const getSignInExperienceSettings = async (): Promise<SignInExperienceSettings> => {
const { signInMethods, socialConnectors, ...rest } =
await getSignInExperience<SignInExperienceSettingsResponse>();
const parseSignInExperienceResponse = (
response: SignInExperienceResponse
): SignInExperienceSettings => {
const { socialConnectors, signUp, ...rest } = response;
const { identifier, ...signUpSettings } = signUp;
return {
...rest,
primarySignInMethod: getPrimarySignInMethod(signInMethods),
secondarySignInMethods: getSecondarySignInMethods(signInMethods),
socialConnectors: filterSocialConnectors(socialConnectors),
signUp: {
methods: signUpIdentifierMap[identifier],
...signUpSettings,
},
};
};
export const getSignInExperienceSettings = async (): Promise<SignInExperienceSettings> => {
const response = await getSignInExperience<SignInExperienceResponse>();
return parseSignInExperienceResponse(response);
};