0
Fork 0
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:
simeng-li 2022-04-26 08:00:19 +08:00 committed by GitHub
parent d2252cef09
commit 5e7744095a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 324 additions and 94 deletions

View file

@ -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: '登录',

View file

@ -77,4 +77,5 @@ export const mockSignInExperienceSettings: SignInExperienceSettings = {
languageInfo: mockSignInExperience.languageInfo,
primarySignInMethod: 'username',
secondarySignInMethods: ['email', 'sms', 'social'],
socialConnectors,
};

View file

@ -1,5 +1,9 @@
@use '@/scss/underscore' as _;
.overlay {
z-index: 100;
}
.container {
background: var(--color-base);
padding: _.unit(6) _.unit(5);

View file

@ -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}
>

View file

@ -8,6 +8,7 @@
width: auto;
@include _.flex-row;
position: relative;
margin-right: _.unit(1);
> select {
appearance: none;

View file

@ -11,10 +11,10 @@
}
.inputField {
margin-bottom: _.unit(11);
margin-bottom: _.unit(12);
}
.terms {
margin-bottom: _.unit(6);
margin-bottom: _.unit(4);
}
}

View file

@ -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;

View file

@ -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');

View file

@ -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)}>

View file

@ -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(() => {

View file

@ -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>
</>
);
};

View file

@ -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>
);

View file

@ -15,6 +15,6 @@
}
.terms {
margin: _.unit(6) 0;
margin: _.unit(8) 0 _.unit(4);
}
}

View file

@ -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,
};

View file

@ -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;

View file

@ -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);
});
});

View file

@ -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"

View 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 />
</>
)}
</>
);
};

View file

@ -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 */

View file

@ -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[];
};

View file

@ -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
};
};