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:
parent
95cf53b59a
commit
3e6021ad16
36 changed files with 155 additions and 1027 deletions
|
@ -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,
|
||||
|
|
|
@ -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>();
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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 */
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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 */
|
|
@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,2 +0,0 @@
|
|||
export { default as SecondarySocialSignIn } from './SecondarySocialSignIn';
|
||||
export { default as PrimarySocialSignIn } from './PrimarySocialSignIn';
|
|
@ -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;
|
|
@ -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(() => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
38
packages/ui/src/pages/SignIn/MainForm.tsx
Normal file
38
packages/ui/src/pages/SignIn/MainForm.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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'),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
/* istanbul ignore file */
|
||||
// FIXME: @simeng-li
|
||||
|
||||
import type { KeyboardEventHandler, KeyboardEvent } from 'react';
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue