mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(ui): render page using sign-in exp settings (#655)
* feat(ui): render page using sign-in exp settings render page using sign-in exp settings * fix(ui): remove unused classname remove unused classname * fix(ui): cr fix cr fix
This commit is contained in:
parent
d2252cef09
commit
5e7744095a
21 changed files with 324 additions and 94 deletions
|
@ -24,7 +24,7 @@ const translation = {
|
|||
},
|
||||
secondary: {
|
||||
sign_in_with: '通过 {{method}} 登录',
|
||||
sign_in_with_2: '通过 {{ methods[0] }} 或 {{ methods[1] }} 登录',
|
||||
sign_in_with_2: '通过 {{ methods.0 }} 或 {{ methods.1 }} 登录',
|
||||
},
|
||||
action: {
|
||||
sign_in: '登录',
|
||||
|
|
|
@ -77,4 +77,5 @@ export const mockSignInExperienceSettings: SignInExperienceSettings = {
|
|||
languageInfo: mockSignInExperience.languageInfo,
|
||||
primarySignInMethod: 'username',
|
||||
secondarySignInMethods: ['email', 'sms', 'social'],
|
||||
socialConnectors,
|
||||
};
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.overlay {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: var(--color-base);
|
||||
padding: _.unit(6) _.unit(5);
|
||||
|
|
|
@ -34,7 +34,7 @@ const ConfirmModal = ({
|
|||
role="dialog"
|
||||
isOpen={isOpen}
|
||||
className={classNames(modalStyles.modal, className)}
|
||||
overlayClassName={modalStyles.overlay}
|
||||
overlayClassName={classNames(modalStyles.overlay, styles.overlay)}
|
||||
parentSelector={() => document.querySelector('main') ?? document.body}
|
||||
ariaHideApp={false}
|
||||
>
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
width: auto;
|
||||
@include _.flex-row;
|
||||
position: relative;
|
||||
margin-right: _.unit(1);
|
||||
|
||||
> select {
|
||||
appearance: none;
|
||||
|
|
|
@ -11,10 +11,10 @@
|
|||
}
|
||||
|
||||
.inputField {
|
||||
margin-bottom: _.unit(11);
|
||||
margin-bottom: _.unit(12);
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-bottom: _.unit(6);
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,10 @@ const SignInMethodsLink = ({ signInMethods, type = 'secondary', className }: Pro
|
|||
[navigate, signInMethods]
|
||||
);
|
||||
|
||||
if (signInMethodsLink.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === 'primary') {
|
||||
return <div className={classNames(styles.methodsPrimary, className)}>{signInMethodsLink}</div>;
|
||||
}
|
||||
|
@ -58,13 +62,13 @@ const SignInMethodsLink = ({ signInMethods, type = 'secondary', className }: Pro
|
|||
rawText
|
||||
);
|
||||
|
||||
return <div className={classNames(styles.methodsSecondary, className)}>{textLink}</div>;
|
||||
return <div className={className}>{textLink}</div>;
|
||||
}
|
||||
|
||||
const rawText = t('secondary.sign_in_with', { method: signInMethods[0] });
|
||||
const textLink = reactStringReplace(rawText, signInMethods[0], () => signInMethodsLink[0]);
|
||||
|
||||
return <div className={classNames(styles.methodsSecondary, className)}>{textLink}</div>;
|
||||
return <div className={className}>{textLink}</div>;
|
||||
};
|
||||
|
||||
export default SignInMethodsLink;
|
||||
|
|
|
@ -1,29 +1,40 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { socialConnectors } from '@/__mocks__/logto';
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { socialConnectors, mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
|
||||
import PrimarySocialSignIn from './PrimarySocialSignIn';
|
||||
import PrimarySocialSignIn, { defaultSize } from './PrimarySocialSignIn';
|
||||
|
||||
describe('SecondarySocialSignIn', () => {
|
||||
it('less than three connectors', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<PrimarySocialSignIn connectors={socialConnectors.slice(0, 3)} />
|
||||
</MemoryRouter>
|
||||
const { container } = renderWithPageContext(
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
socialConnectors: socialConnectors.slice(0, defaultSize),
|
||||
}}
|
||||
>
|
||||
<MemoryRouter>
|
||||
<PrimarySocialSignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelectorAll('button')).toHaveLength(3);
|
||||
expect(container.querySelectorAll('button')).toHaveLength(defaultSize);
|
||||
});
|
||||
|
||||
it('more than three connectors', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<PrimarySocialSignIn connectors={socialConnectors} />
|
||||
</MemoryRouter>
|
||||
const { container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<MemoryRouter>
|
||||
<PrimarySocialSignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('button')).toHaveLength(3);
|
||||
expect(container.querySelectorAll('button')).toHaveLength(defaultSize);
|
||||
|
||||
const expandButton = container.querySelector('svg');
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { ConnectorMetadata } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
|
@ -8,25 +7,27 @@ import useSocial from '@/hooks/use-social';
|
|||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export const defaultSize = 3;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
connectors: Array<Pick<ConnectorMetadata, 'id' | 'logo' | 'name'>>;
|
||||
isPopup?: boolean;
|
||||
onSocialSignInCallback?: () => void;
|
||||
};
|
||||
|
||||
const PrimarySocialSignIn = ({ className, connectors, isPopup = false }: Props) => {
|
||||
const PrimarySocialSignIn = ({ className, isPopup = false, onSocialSignInCallback }: Props) => {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const { invokeSocialSignIn } = useSocial();
|
||||
const isOverSize = connectors.length > 3;
|
||||
const { invokeSocialSignIn, socialConnectors } = useSocial({ onSocialSignInCallback });
|
||||
const isOverSize = socialConnectors.length > defaultSize;
|
||||
const displayAll = showAll || isPopup || !isOverSize;
|
||||
|
||||
const displayConnectors = useMemo(() => {
|
||||
if (displayAll) {
|
||||
return connectors;
|
||||
return socialConnectors;
|
||||
}
|
||||
|
||||
return connectors.slice(0, 3);
|
||||
}, [connectors, displayAll]);
|
||||
return socialConnectors.slice(0, defaultSize);
|
||||
}, [socialConnectors, displayAll]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.socialLinkList, className)}>
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import { socialConnectors } from '@/__mocks__/logto';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { socialConnectors, mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
import * as socialSignInApi from '@/apis/social';
|
||||
import { generateState, storeState } from '@/hooks/use-social';
|
||||
|
||||
import SecondarySocialSignIn from './SecondarySocialSignIn';
|
||||
import SecondarySocialSignIn, { defaultSize } from './SecondarySocialSignIn';
|
||||
|
||||
describe('SecondarySocialSignIn', () => {
|
||||
const mockOrigin = 'https://logto.dev';
|
||||
|
@ -27,6 +28,7 @@ describe('SecondarySocialSignIn', () => {
|
|||
platform: 'web',
|
||||
getPostMessage: jest.fn(() => jest.fn()),
|
||||
callbackLink: '/logto:',
|
||||
supportedSocialConnectors: socialConnectors.map(({ id }) => id),
|
||||
};
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
});
|
||||
|
@ -36,21 +38,30 @@ describe('SecondarySocialSignIn', () => {
|
|||
});
|
||||
|
||||
it('less than four connectors', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<SecondarySocialSignIn connectors={socialConnectors.slice(0, 3)} />
|
||||
</MemoryRouter>
|
||||
const { container } = renderWithPageContext(
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
socialConnectors: socialConnectors.slice(0, defaultSize - 1),
|
||||
}}
|
||||
>
|
||||
<MemoryRouter>
|
||||
<SecondarySocialSignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelectorAll('button')).toHaveLength(3);
|
||||
expect(container.querySelectorAll('button')).toHaveLength(defaultSize - 1);
|
||||
});
|
||||
|
||||
it('more than four connectors', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<SecondarySocialSignIn connectors={socialConnectors} />
|
||||
</MemoryRouter>
|
||||
const { container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<MemoryRouter>
|
||||
<SecondarySocialSignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelectorAll('button')).toHaveLength(3);
|
||||
expect(container.querySelectorAll('button')).toHaveLength(defaultSize - 1);
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
|
@ -58,9 +69,17 @@ describe('SecondarySocialSignIn', () => {
|
|||
const connectors = socialConnectors.slice(0, 1);
|
||||
|
||||
const { container } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SecondarySocialSignIn connectors={connectors} />
|
||||
</MemoryRouter>
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
termsOfUse: { enabled: false },
|
||||
socialConnectors: connectors,
|
||||
}}
|
||||
>
|
||||
<MemoryRouter>
|
||||
<SecondarySocialSignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
const socialButton = container.querySelector('button');
|
||||
|
||||
|
@ -81,9 +100,17 @@ describe('SecondarySocialSignIn', () => {
|
|||
|
||||
const connectors = socialConnectors.slice(0, 1);
|
||||
const { container } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SecondarySocialSignIn connectors={connectors} />
|
||||
</MemoryRouter>
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
termsOfUse: { enabled: false },
|
||||
socialConnectors: connectors,
|
||||
}}
|
||||
>
|
||||
<MemoryRouter>
|
||||
<SecondarySocialSignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
const socialButton = container.querySelector('button');
|
||||
|
||||
|
@ -98,8 +125,6 @@ describe('SecondarySocialSignIn', () => {
|
|||
});
|
||||
|
||||
it('callback validation and signIn with social', async () => {
|
||||
const connectors = socialConnectors.slice(0, 1);
|
||||
|
||||
const state = generateState();
|
||||
storeState(state, 'github');
|
||||
|
||||
|
@ -115,14 +140,13 @@ describe('SecondarySocialSignIn', () => {
|
|||
/* eslint-enable @silverhand/fp/no-mutating-methods */
|
||||
|
||||
renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/sign-in/callback/github']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/sign-in/callback/:connector"
|
||||
element={<SecondarySocialSignIn connectors={connectors} />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<MemoryRouter initialEntries={['/sign-in/callback/github']}>
|
||||
<Routes>
|
||||
<Route path="/sign-in/callback/:connector" element={<SecondarySocialSignIn />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
|
@ -1,47 +1,63 @@
|
|||
import { ConnectorMetadata } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import SocialIconButton from '@/components/Button/SocialIconButton';
|
||||
import MoreSocialIcon from '@/components/Icons/MoreSocialIcon';
|
||||
import useSocial from '@/hooks/use-social';
|
||||
|
||||
import SocialSignInPopUp from './SocialSignInPopUp';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export const defaultSize = 4;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
connectors: Array<Pick<ConnectorMetadata, 'id' | 'logo'>>;
|
||||
showMoreConnectors?: () => void;
|
||||
};
|
||||
|
||||
const SecondarySocialSignIn = ({ className, connectors, showMoreConnectors }: Props) => {
|
||||
const { invokeSocialSignIn } = useSocial();
|
||||
const isOverSize = connectors.length > 4;
|
||||
const SecondarySocialSignIn = ({ className }: Props) => {
|
||||
const { socialConnectors, invokeSocialSignIn } = useSocial();
|
||||
const isOverSize = socialConnectors.length > defaultSize;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const displayConnectors = useMemo(() => {
|
||||
if (isOverSize) {
|
||||
return connectors.slice(0, 3);
|
||||
return socialConnectors.slice(0, defaultSize - 1);
|
||||
}
|
||||
|
||||
return connectors;
|
||||
}, [connectors, isOverSize]);
|
||||
return socialConnectors;
|
||||
}, [socialConnectors, isOverSize]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.socialIconList, className)}>
|
||||
{displayConnectors.map((connector) => (
|
||||
<SocialIconButton
|
||||
key={connector.id}
|
||||
className={styles.socialButton}
|
||||
connector={connector}
|
||||
onClick={() => {
|
||||
void invokeSocialSignIn(connector.id);
|
||||
<>
|
||||
<div className={classNames(styles.socialIconList, className)}>
|
||||
{displayConnectors.map((connector) => (
|
||||
<SocialIconButton
|
||||
key={connector.id}
|
||||
className={styles.socialButton}
|
||||
connector={connector}
|
||||
onClick={() => {
|
||||
void invokeSocialSignIn(connector.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{isOverSize && (
|
||||
<MoreSocialIcon
|
||||
className={styles.socialButton}
|
||||
onClick={() => {
|
||||
setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isOverSize && (
|
||||
<SocialSignInPopUp
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{isOverSize && (
|
||||
<MoreSocialIcon className={styles.socialButton} onClick={showMoreConnectors} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { ConnectorMetadata } from '@logto/schemas';
|
||||
import React from 'react';
|
||||
|
||||
import Drawer from '@/components/Drawer';
|
||||
|
@ -9,12 +8,11 @@ type Props = {
|
|||
isOpen?: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
connectors: Array<Pick<ConnectorMetadata, 'id' | 'logo' | 'name'>>;
|
||||
};
|
||||
|
||||
const SocialSignInPopUp = ({ isOpen = false, onClose, className, connectors }: Props) => (
|
||||
const SocialSignInPopUp = ({ isOpen = false, onClose, className }: Props) => (
|
||||
<Drawer className={className} isOpen={isOpen} onClose={onClose}>
|
||||
<PrimarySocialSignIn isPopup connectors={connectors} />
|
||||
<PrimarySocialSignIn isPopup onSocialSignInCallback={onClose} />
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
|
|
|
@ -15,6 +15,6 @@
|
|||
}
|
||||
|
||||
.terms {
|
||||
margin: _.unit(6) 0;
|
||||
margin: _.unit(8) 0 _.unit(4);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useCallback, useContext } from 'react';
|
||||
import { useEffect, useCallback, useContext, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { invokeSocialSignIn, signInWithSocial } from '@/apis/social';
|
||||
|
@ -22,6 +22,10 @@ type State = {
|
|||
callbackLink?: string;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
onSocialSignInCallback?: () => void;
|
||||
};
|
||||
|
||||
const storageKeyPrefix = 'social_auth_state';
|
||||
|
||||
const getLogtoNativeSdk = () => {
|
||||
|
@ -64,11 +68,20 @@ const isNativeWebview = () => {
|
|||
return ['ios', 'android'].includes(platform);
|
||||
};
|
||||
|
||||
const useSocial = () => {
|
||||
const { setToast } = useContext(PageContext);
|
||||
const useSocial = (options?: Options) => {
|
||||
const { setToast, experienceSettings } = useContext(PageContext);
|
||||
const { termsValidation } = useTerms();
|
||||
const parameters = useParams();
|
||||
|
||||
// Filter native supported social connectors
|
||||
const socialConnectors = useMemo(
|
||||
() =>
|
||||
(experienceSettings?.socialConnectors ?? []).filter(({ id }) => {
|
||||
return !isNativeWebview() || getLogtoNativeSdk()?.supportedSocialConnectors.includes(id);
|
||||
}),
|
||||
[experienceSettings?.socialConnectors]
|
||||
);
|
||||
|
||||
const { result: invokeSocialSignInResult, run: asyncInvokeSocialSignIn } =
|
||||
useApi(invokeSocialSignIn);
|
||||
|
||||
|
@ -151,6 +164,8 @@ const useSocial = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
options?.onSocialSignInCallback?.();
|
||||
|
||||
// Invoke Native Social Sign In flow
|
||||
if (isNativeWebview()) {
|
||||
getLogtoNativeSdk()?.getPostMessage()({
|
||||
|
@ -163,7 +178,7 @@ const useSocial = () => {
|
|||
|
||||
// Invoke Web Social Sign In flow
|
||||
window.location.assign(redirectTo);
|
||||
}, [invokeSocialSignInResult]);
|
||||
}, [invokeSocialSignInResult, options]);
|
||||
|
||||
// SignInWithSocial Callback
|
||||
useEffect(() => {
|
||||
|
@ -189,6 +204,10 @@ const useSocial = () => {
|
|||
|
||||
// Monitor Native Error Message
|
||||
useEffect(() => {
|
||||
if (!isNativeWebview()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nativeMessageHandler = (event: MessageEvent) => {
|
||||
if (event.origin === window.location.origin) {
|
||||
setToast(JSON.stringify(event.data));
|
||||
|
@ -203,6 +222,7 @@ const useSocial = () => {
|
|||
}, [setToast]);
|
||||
|
||||
return {
|
||||
socialConnectors,
|
||||
invokeSocialSignIn: invokeSocialSignInHandler,
|
||||
socialCallbackHandler,
|
||||
};
|
||||
|
|
|
@ -9,6 +9,25 @@
|
|||
margin-bottom: _.unit(12);
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
|
||||
.primarySignIn {
|
||||
margin-bottom: _.unit(5);
|
||||
}
|
||||
|
||||
.primarySocial {
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.otherMethodsLink {
|
||||
margin-top: _.unit(1);
|
||||
}
|
||||
|
||||
.createAccount {
|
||||
position: fixed;
|
||||
|
|
|
@ -1,11 +1,61 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
import SignIn from '@/pages/SignIn';
|
||||
|
||||
describe('<SignIn />', () => {
|
||||
test('renders without exploding', async () => {
|
||||
const { queryByText } = render(<SignIn />);
|
||||
test('renders with username as primary', async () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<MemoryRouter>
|
||||
<SignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelector('input[name="username"]')).not.toBeNull();
|
||||
expect(queryByText('action.sign_in')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('renders with email as primary', async () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<SettingsProvider
|
||||
settings={{ ...mockSignInExperienceSettings, primarySignInMethod: 'email' }}
|
||||
>
|
||||
<MemoryRouter>
|
||||
<SignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelector('input[name="email"]')).not.toBeNull();
|
||||
expect(queryByText('action.continue')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('renders with sms as primary', async () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<SettingsProvider settings={{ ...mockSignInExperienceSettings, primarySignInMethod: 'sms' }}>
|
||||
<MemoryRouter>
|
||||
<SignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
|
||||
expect(queryByText('action.continue')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('renders with social as primary', async () => {
|
||||
const { container } = renderWithPageContext(
|
||||
<SettingsProvider
|
||||
settings={{ ...mockSignInExperienceSettings, primarySignInMethod: 'social' }}
|
||||
>
|
||||
<MemoryRouter>
|
||||
<SignIn />
|
||||
</MemoryRouter>
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('button')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,10 +4,10 @@ import React, { useContext } from 'react';
|
|||
|
||||
import BrandingHeader from '@/components/BrandingHeader';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import UsernameSignin from '@/containers/UsernameSignin';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
import { PrimarySection, SecondarySection } from './registry';
|
||||
|
||||
const SignIn = () => {
|
||||
const { experienceSettings } = useContext(PageContext);
|
||||
|
@ -21,7 +21,11 @@ const SignIn = () => {
|
|||
headline={style === BrandingStyle.Logo_Slogan ? slogan : undefined}
|
||||
logo={logoUrl}
|
||||
/>
|
||||
<UsernameSignin />
|
||||
<PrimarySection signInMethod={experienceSettings?.primarySignInMethod} />
|
||||
<SecondarySection
|
||||
primarySignInMethod={experienceSettings?.primarySignInMethod}
|
||||
secondarySignInMethods={experienceSettings?.secondarySignInMethods}
|
||||
/>
|
||||
<TextLink
|
||||
className={styles.createAccount}
|
||||
type="secondary"
|
||||
|
|
72
packages/ui/src/pages/SignIn/registry.tsx
Normal file
72
packages/ui/src/pages/SignIn/registry.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import React from 'react';
|
||||
|
||||
import Divider from '@/components/Divider';
|
||||
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
|
||||
import SignInMethodsLink from '@/containers/SignInMethodsLink';
|
||||
import { PrimarySocialSignIn, SecondarySocialSignIn } from '@/containers/SocialSignIn';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import UsernameSignin from '@/containers/UsernameSignin';
|
||||
import { SignInMethod, LocalSignInMethod } from '@/types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export const PrimarySection = ({ signInMethod }: { signInMethod?: SignInMethod }) => {
|
||||
switch (signInMethod) {
|
||||
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} />;
|
||||
case 'social':
|
||||
return (
|
||||
<>
|
||||
<TermsOfUse className={styles.terms} />
|
||||
<PrimarySocialSignIn className={styles.primarySocial} />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const SecondarySection = ({
|
||||
primarySignInMethod,
|
||||
secondarySignInMethods,
|
||||
}: {
|
||||
primarySignInMethod?: SignInMethod;
|
||||
secondarySignInMethods?: SignInMethod[];
|
||||
}) => {
|
||||
if (!primarySignInMethod || !secondarySignInMethods?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localMethods = secondarySignInMethods.filter(
|
||||
(method): method is LocalSignInMethod => method !== 'social'
|
||||
);
|
||||
|
||||
if (primarySignInMethod === 'social' && localMethods.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<Divider label="description.continue_with" className={styles.divider} />
|
||||
<SignInMethodsLink
|
||||
signInMethods={localMethods}
|
||||
type="primary"
|
||||
className={styles.otherMethodsLink}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SignInMethodsLink signInMethods={localMethods} />
|
||||
{secondarySignInMethods.includes('social') && (
|
||||
<>
|
||||
<Divider label="description.continue_with" className={styles.divider} />
|
||||
<SecondarySocialSignIn />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -29,7 +29,7 @@
|
|||
:global {
|
||||
.ReactModal__Content[role='popup'] {
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3 ease-in-out;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import { Branding, LanguageInfo, TermsOfUse } from '@logto/schemas';
|
||||
import { Branding, LanguageInfo, TermsOfUse, ConnectorMetadata } from '@logto/schemas';
|
||||
|
||||
export type UserFlow = 'sign-in' | 'register';
|
||||
export type SignInMethod = 'username' | 'email' | 'sms' | 'social';
|
||||
export type LocalSignInMethod = 'username' | 'email' | 'sms';
|
||||
|
||||
type ConnectorData = Pick<ConnectorMetadata, 'id' | 'logo' | 'name'>;
|
||||
|
||||
export type SignInExperienceSettings = {
|
||||
branding: Branding;
|
||||
languageInfo: LanguageInfo;
|
||||
termsOfUse: TermsOfUse;
|
||||
primarySignInMethod: SignInMethod;
|
||||
secondarySignInMethods: SignInMethod[];
|
||||
socialConnectors: ConnectorData[];
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { SignInMethods } from '@logto/schemas';
|
||||
|
||||
import { socialConnectors } from '@/__mocks__/logto';
|
||||
import { getSignInExperience } from '@/apis/settings';
|
||||
import { SignInMethod, SignInExperienceSettings } from '@/types';
|
||||
|
||||
|
@ -36,6 +37,7 @@ const getSignInExperienceSettings = async (): Promise<SignInExperienceSettings>
|
|||
termsOfUse,
|
||||
primarySignInMethod: getPrimarySignInMethod(signInMethods),
|
||||
secondarySignInMethods: getSecondarySignInMethods(signInMethods),
|
||||
socialConnectors, // TODO: get values from api
|
||||
};
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue