0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(ui): refactor sign-in page ()

This commit is contained in:
simeng-li 2022-10-31 10:39:41 +08:00 committed by GitHub
parent 305bbaad2c
commit e1d3d34523
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 383 additions and 253 deletions

View file

@ -15,10 +15,8 @@
"test:ci": "jest --coverage --silent",
"test": "jest"
},
"dependencies": {
"@logto/core-kit": "1.0.0-beta.20"
},
"devDependencies": {
"@logto/core-kit": "1.0.0-beta.20",
"@logto/language-kit": "1.0.0-beta.20",
"@logto/phrases": "workspace:^",
"@logto/phrases-ui": "workspace:^",

View file

@ -13,6 +13,7 @@ import ForgotPassword from './pages/ForgotPassword';
import Passcode from './pages/Passcode';
import Register from './pages/Register';
import ResetPassword from './pages/ResetPassword';
import SecondaryRegister from './pages/SecondaryRegister';
import SecondarySignIn from './pages/SecondarySignIn';
import SignIn from './pages/SignIn';
import SocialLanding from './pages/SocialLanding';
@ -67,7 +68,7 @@ const App = () => {
{/* register */}
<Route path="/register" element={<Register />} />
<Route path="/register/:method" element={<Register />} />
<Route path="/register/:method" element={<SecondaryRegister />} />
{/* forgot password */}
<Route path="/forgot-password/reset" element={<ResetPassword />} />

View file

@ -2,6 +2,7 @@ import classNames from 'classnames';
import type { ReactNode, AnchorHTMLAttributes } from 'react';
import type { TFuncKey } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import type { LinkProps } from 'react-router-dom';
import { Link } from 'react-router-dom';
import * as styles from './index.module.scss';
@ -11,15 +12,14 @@ export type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
children?: ReactNode;
text?: TFuncKey;
type?: 'primary' | 'secondary';
to?: string;
};
} & Partial<LinkProps>;
const TextLink = ({ className, children, text, type = 'primary', to, ...rest }: Props) => {
const { t } = useTranslation();
if (to) {
return (
<Link className={classNames(styles.link, styles[type], className)} to={to}>
<Link className={classNames(styles.link, styles[type], className)} to={to} {...rest}>
{children ?? (text ? t(text) : '')}
</Link>
);

View file

@ -0,0 +1,9 @@
type Props = {
className?: string;
};
const EmailPassword = ({ className }: Props) => {
return <div className={className}>email password form</div>;
};
export default EmailPassword;

View file

@ -0,0 +1,28 @@
@use '@/scss/underscore' as _;
.wrapper {
@include _.full-page;
@include _.flex-column(normal, normal);
@include _.full-width;
}
:global(body.mobile) {
.header {
margin-top: _.unit(3);
margin-bottom: _.unit(7);
}
}
:global(body.desktop) {
.header {
margin-bottom: _.unit(6);
}
.placeholderTop {
flex: 3;
}
.placeholderBottom {
flex: 5;
}
}

View file

@ -0,0 +1,44 @@
import { BrandingStyle } from '@logto/schemas';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useContext } from 'react';
import BrandingHeader from '@/components/BrandingHeader';
import AppNotification from '@/containers/AppNotification';
import { PageContext } from '@/hooks/use-page-context';
import { getLogoUrl } from '@/utils/logo';
import * as styles from './index.module.scss';
type Props = {
children: ReactNode;
className?: string;
};
const LandingPageContainer = ({ children, className }: Props) => {
const { experienceSettings, theme, platform } = useContext(PageContext);
if (!experienceSettings) {
return null;
}
const { slogan, logoUrl, darkLogoUrl, style } = experienceSettings.branding;
return (
<>
{platform === 'web' && <div className={styles.placeholderTop} />}
<div className={classNames(styles.wrapper, className)}>
<BrandingHeader
className={styles.header}
headline={style === BrandingStyle.Logo_Slogan ? slogan : undefined}
logo={getLogoUrl({ theme, logoUrl, darkLogoUrl })}
/>
{children}
<AppNotification />
</div>
{platform === 'web' && <div className={styles.placeholderBottom} />}
</>
);
};
export default LandingPageContainer;

View file

@ -1,52 +1,29 @@
import { fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import PasswordlessSwitch from './PasswordlessSwitch';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('<PasswordlessSwitch />', () => {
afterEach(() => {
mockedNavigate.mockClear();
});
test('render sms passwordless switch', () => {
const { queryByText, getByText } = renderWithPageContext(
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/forgot-password/sms']}>
<PasswordlessSwitch target="email" />
</MemoryRouter>
);
expect(queryByText('action.switch_to')).not.toBeNull();
const link = getByText('action.switch_to');
fireEvent.click(link);
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/forgot-password/email' },
{ replace: true }
);
expect(container.querySelector('a')?.getAttribute('href')).toBe('/forgot-password/email');
});
test('render email passwordless switch', () => {
const { queryByText, getByText } = renderWithPageContext(
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/forgot-password/email']}>
<PasswordlessSwitch target="sms" />
</MemoryRouter>
);
expect(queryByText('action.switch_to')).not.toBeNull();
const link = getByText('action.switch_to');
fireEvent.click(link);
expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/sms' }, { replace: true });
expect(container.querySelector('a')?.getAttribute('href')).toBe('/forgot-password/sms');
});
});

View file

@ -16,17 +16,7 @@ const PasswordlessSwitch = ({ target, className }: Props) => {
const targetPathname = pathname.replace(target === 'email' ? 'sms' : 'email', target);
return (
<TextLink
className={className}
onClick={() => {
navigate(
{
pathname: targetPathname,
},
{ replace: true }
);
}}
>
<TextLink replace className={className} to={targetPathname}>
{t('action.switch_to', {
method: t(`description.${target === 'email' ? 'email' : 'phone_number'}`),
})}

View file

@ -0,0 +1,9 @@
type Props = {
className?: string;
};
const PhonePassword = ({ className }: Props) => {
return <div className={className}>Phone password form</div>;
};
export default PhonePassword;

View file

@ -1,51 +1,47 @@
import type { SignIn } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import type { TFuncKey } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import reactStringReplace from 'react-string-replace';
import TextLink from '@/components/TextLink';
import type { LocalSignInMethod } from '@/types';
import * as styles from './index.module.scss';
type Props = {
signInMethods: LocalSignInMethod[];
signInMethods: SignIn['methods'];
// Allows social page to pass additional query params to the sign-in pages
search?: string;
className?: string;
template?: TFuncKey<'translation', 'secondary'>;
};
const SignInMethodsKeyMap: {
[key in LocalSignInMethod]: TFuncKey<'translation', 'input'>;
[key in SignInIdentifier]: TFuncKey<'translation', 'input'>;
} = {
username: 'username',
email: 'email',
sms: 'phone_number',
[SignInIdentifier.Username]: 'username',
[SignInIdentifier.Email]: 'email',
[SignInIdentifier.Sms]: 'phone_number',
};
const SignInMethodsLink = ({ signInMethods, template, search, className }: Props) => {
const navigate = useNavigate();
const { t } = useTranslation();
const identifiers = signInMethods.map(({ identifier }) => identifier);
const signInMethodsLink = useMemo(
() =>
signInMethods.map((method) => (
identifiers.map((identifier) => (
<TextLink
key={method}
key={identifier}
className={styles.signInMethodLink}
text={`input.${SignInMethodsKeyMap[method]}`}
onClick={() => {
navigate({
pathname: `/sign-in/${method}`,
search,
});
}}
text={`input.${SignInMethodsKeyMap[identifier]}`}
to={{ pathname: `/sign-in/${identifier}`, search }}
/>
)),
[navigate, search, signInMethods]
[identifiers, search]
);
if (signInMethodsLink.length === 0) {
@ -58,11 +54,11 @@ const SignInMethodsLink = ({ signInMethods, template, search, className }: Props
}
// With text template
const rawText = t(`secondary.${template}`, { methods: signInMethods });
const textWithLink: ReactNode = signInMethods.reduce<ReactNode>(
(content, method, index) =>
const rawText = t(`secondary.${template}`, { methods: identifiers });
const textWithLink: ReactNode = identifiers.reduce<ReactNode>(
(content, identifier, index) =>
// @ts-expect-error: reactStringReplace type bug, using deprecated ReactNodeArray as its input type
reactStringReplace(content, method, () => signInMethodsLink[index]),
reactStringReplace(content, identifier, () => signInMethodsLink[index]),
rawText
);

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import useBindSocial from '@/hooks/use-bind-social';
import { useSieMethods } from '@/hooks/use-sie';
import { SearchParameters } from '@/types';
import { queryStringify } from '@/utils';
@ -16,8 +17,8 @@ type Props = {
const SocialCreateAccount = ({ connectorId, className }: Props) => {
const { t } = useTranslation();
const { relatedUser, localSignInMethods, registerWithSocial, bindSocialRelatedUser } =
useBindSocial();
const { relatedUser, registerWithSocial, bindSocialRelatedUser } = useBindSocial();
const { signInMethods } = useSieMethods();
return (
<div className={classNames(styles.container, className)}>
@ -42,7 +43,7 @@ const SocialCreateAccount = ({ connectorId, className }: Props) => {
}}
/>
<SignInMethodsLink
signInMethods={localSignInMethods}
signInMethods={signInMethods}
template="social_bind_with"
className={styles.desc}
search={queryStringify({ [SearchParameters.bindWithSocial]: connectorId })}

View file

@ -16,14 +16,16 @@ describe('SocialSignInList', () => {
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelectorAll('button')).toHaveLength(defaultSize);
expect(container.querySelectorAll('button')).toHaveLength(
socialConnectors.slice(0, defaultSize).length
);
});
it('more than three connectors', () => {
const { container } = renderWithPageContext(
<SettingsProvider>
<MemoryRouter>
<SocialSignInList socialConnectors={socialConnectors} />
<SocialSignInList isCollapseEnabled socialConnectors={socialConnectors} />
</MemoryRouter>
</SettingsProvider>
);

View file

@ -16,15 +16,9 @@ type Props = {
className?: string;
socialConnectors?: ConnectorMetadata[];
isCollapseEnabled?: boolean;
onSocialSignInCallback?: () => void;
};
const SocialSignInList = ({
className,
socialConnectors = [],
isCollapseEnabled = true,
onSocialSignInCallback,
}: Props) => {
const SocialSignInList = ({ className, socialConnectors = [], isCollapseEnabled }: Props) => {
const [expand, setExpand] = useState(false);
const { invokeSocialSignIn, theme } = useSocial();
const isOverSize = socialConnectors.length > defaultSize;
@ -52,7 +46,6 @@ const SocialSignInList = ({
target={target}
onClick={() => {
void invokeSocialSignIn(connector);
onSocialSignInCallback?.();
}}
/>
);

View file

@ -5,8 +5,6 @@ import useSocial from '@/hooks/use-social';
import SocialSignInList from './SocialSignInList';
import * as styles from './index.module.scss';
export const defaultSize = 3;
type Props = {
className?: string;
};
@ -24,3 +22,5 @@ const SocialSignIn = ({ className }: Props) => {
};
export default SocialSignIn;
export { default as SocialSignInList } from './SocialSignInList';

View file

@ -1,16 +1,14 @@
import { conditional } from '@silverhand/essentials';
import { useCallback, useEffect, useContext, useMemo } from 'react';
import { useCallback, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';
import useApi from '@/hooks/use-api';
import { PageContext } from '@/hooks/use-page-context';
import { bindSocialStateGuard } from '@/types/guard';
const useBindSocial = () => {
const { state } = useLocation();
const { experienceSettings } = useContext(PageContext);
const { result: registerResult, run: asyncRegisterWithSocial } = useApi(registerWithSocial);
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(bindSocialRelatedUser);
@ -28,13 +26,6 @@ const useBindSocial = () => {
[asyncBindSocialRelatedUser]
);
// TODO: @simeng LOG-4487
const localSignInMethods = useMemo(() => {
const signInMethods = experienceSettings?.signIn.methods ?? [];
return signInMethods.map(({ identifier }) => identifier);
}, [experienceSettings]);
useEffect(() => {
if (registerResult?.redirectTo) {
window.location.replace(registerResult.redirectTo);
@ -48,7 +39,6 @@ const useBindSocial = () => {
}, [bindUserResult]);
return {
localSignInMethods,
relatedUser: conditional(is(state, bindSocialStateGuard) && state.relatedUser),
registerWithSocial: createAccountHandler,
bindSocialRelatedUser: bindRelatedUserHandler,

View file

@ -0,0 +1,13 @@
import { useContext } from 'react';
import { PageContext } from './use-page-context';
export const useSieMethods = () => {
const { experienceSettings } = useContext(PageContext);
return {
signUpMethods: experienceSettings?.signUp.methods ?? [],
signInMethods: experienceSettings?.signIn.methods ?? [],
socialConnectors: experienceSettings?.socialConnectors ?? [],
};
};

View file

@ -1,50 +1,16 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useContext } from 'react';
import NavBar from '@/components/NavBar';
import CreateAccount from '@/containers/CreateAccount';
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
import ErrorPage from '@/pages/ErrorPage';
import * as styles from './index.module.scss';
type Parameters = {
method?: string;
};
import LandingPageContainer from '@/containers/LandingPageContainer';
import { PageContext } from '@/hooks/use-page-context';
const Register = () => {
const { t } = useTranslation();
const { method = 'username' } = useParams<Parameters>();
const { experienceSettings } = useContext(PageContext);
const registerForm = useMemo(() => {
if (method === 'sms') {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <PhonePasswordless autoFocus type="register" />;
}
if (method === 'email') {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <EmailPasswordless autoFocus type="register" />;
}
// eslint-disable-next-line jsx-a11y/no-autofocus
return <CreateAccount autoFocus />;
}, [method]);
if (!['email', 'sms', 'username'].includes(method)) {
return <ErrorPage />;
if (!experienceSettings) {
return null;
}
return (
<div className={styles.wrapper}>
<NavBar />
<div className={styles.container}>
<div className={styles.title}>{t('action.create_account')}</div>
{registerForm}
</div>
</div>
);
return <LandingPageContainer>signUp</LandingPageContainer>;
};
export default Register;

View file

@ -1,18 +1,18 @@
import { render } from '@testing-library/react';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import Register from '@/pages/Register';
import SecondaryRegister from '@/pages/SecondaryRegister';
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => 0) }));
jest.mock('i18next', () => ({
language: 'en',
}));
describe('<Register />', () => {
describe('<SecondaryRegister />', () => {
test('renders without exploding', async () => {
const { queryByText } = render(
<MemoryRouter initialEntries={['/register']}>
<Register />
<SecondaryRegister />
</MemoryRouter>
);
expect(queryByText('action.create_account')).not.toBeNull();
@ -23,7 +23,7 @@ describe('<Register />', () => {
const { queryByText, container } = render(
<MemoryRouter initialEntries={['/register/sms']}>
<Routes>
<Route path="/register/:method" element={<Register />} />
<Route path="/register/:method" element={<SecondaryRegister />} />
</Routes>
</MemoryRouter>
);
@ -35,7 +35,7 @@ describe('<Register />', () => {
const { queryByText, container } = render(
<MemoryRouter initialEntries={['/register/email']}>
<Routes>
<Route path="/register/:method" element={<Register />} />
<Route path="/register/:method" element={<SecondaryRegister />} />
</Routes>
</MemoryRouter>
);
@ -47,7 +47,7 @@ describe('<Register />', () => {
const { queryByText } = render(
<MemoryRouter initialEntries={['/register/test']}>
<Routes>
<Route path="/register/:method" element={<Register />} />
<Route path="/register/:method" element={<SecondaryRegister />} />
</Routes>
</MemoryRouter>
);

View file

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import NavBar from '@/components/NavBar';
import CreateAccount from '@/containers/CreateAccount';
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
import ErrorPage from '@/pages/ErrorPage';
import * as styles from './index.module.scss';
type Parameters = {
method?: string;
};
const SecondaryRegister = () => {
const { t } = useTranslation();
const { method = 'username' } = useParams<Parameters>();
const registerForm = useMemo(() => {
if (method === 'sms') {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <PhonePasswordless autoFocus type="register" />;
}
if (method === 'email') {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <EmailPasswordless autoFocus type="register" />;
}
// eslint-disable-next-line jsx-a11y/no-autofocus
return <CreateAccount autoFocus />;
}, [method]);
if (!['email', 'sms', 'username'].includes(method)) {
return <ErrorPage />;
}
return (
<div className={styles.wrapper}>
<NavBar />
<div className={styles.container}>
<div className={styles.title}>{t('action.create_account')}</div>
{registerForm}
</div>
</div>
);
};
export default SecondaryRegister;

View file

@ -0,0 +1,53 @@
import type { SignIn as SignInType, ConnectorMetadata } from '@logto/schemas';
import EmailPassword from '@/containers/EmailPassword';
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
import PhonePassword from '@/containers/PhonePassword';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameSignIn from '@/containers/UsernameSignIn';
import type { ArrayElement } from '@/types';
import * as styles from './index.module.scss';
type Props = {
signInMethod?: ArrayElement<SignInType['methods']>;
socialConnectors: ConnectorMetadata[];
};
const Main = ({ signInMethod, socialConnectors }: Props) => {
if (!signInMethod) {
return socialConnectors.length > 0 ? <SocialSignIn /> : null;
}
switch (signInMethod.identifier) {
case 'email': {
if (signInMethod.password && !signInMethod.verificationCode) {
return <EmailPassword className={styles.main} />;
}
return <EmailPasswordless type="sign-in" className={styles.main} />;
}
case 'sms': {
if (signInMethod.password && !signInMethod.verificationCode) {
return <PhonePassword className={styles.main} />;
}
return <PhonePasswordless type="sign-in" className={styles.main} />;
}
case 'username': {
return <UsernameSignIn className={styles.main} />;
}
default: {
if (socialConnectors.length > 0) {
return <SocialSignIn />;
}
return null;
}
}
};
export default Main;

View file

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

View file

@ -1,46 +1,24 @@
@use '@/scss/underscore' as _;
.wrapper {
@include _.full-page;
@include _.flex-column(normal, normal);
@include _.full-width;
.primarySignIn {
margin-bottom: _.unit(5);
}
.otherMethodsLink {
margin-bottom: _.unit(6);
}
.createAccount {
margin-top: _.unit(6);
text-align: center;
}
.placeHolder {
flex: 1;
}
.main {
margin-bottom: _.unit(4);
}
.placeholderTop {
flex: 3;
.otherMethodsLink {
margin-bottom: _.unit(6);
}
.placeholderBottom {
flex: 5;
.createAccount {
margin-top: _.unit(6);
text-align: center;
}
.placeHolder {
flex: 1;
}
:global(body.mobile) {
.header {
margin-top: _.unit(3);
margin-bottom: _.unit(12);
}
.primarySocial {
margin-bottom: _.unit(8);
}
.divider {
margin-bottom: _.unit(5);
}
@ -51,14 +29,10 @@
}
:global(body.desktop) {
.header {
.main {
margin-bottom: _.unit(6);
}
.primarySocial {
margin-bottom: _.unit(12);
}
.placeHolder {
flex: 0;
}

View file

@ -16,18 +16,25 @@ jest.mock('i18next', () => ({
describe('<SignIn />', () => {
test('renders with username as primary', async () => {
const { queryByText, container } = renderWithPageContext(
const { queryByText, queryAllByText, container } = renderWithPageContext(
<SettingsProvider>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="username"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
// Other sign-in methods
expect(queryByText('secondary.sign_in_with')).not.toBeNull();
// Social
expect(queryAllByText('action.sign_in_with')).toHaveLength(defaultSize);
});
test('renders with email as primary', async () => {
test('renders with email passwordless as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
@ -44,7 +51,31 @@ describe('<SignIn />', () => {
expect(queryByText('action.continue')).not.toBeNull();
});
test('renders with sms as primary', async () => {
test('render with email password as primary', async () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signIn: {
methods: [
{
...emailSignInMethod,
verificationCode: false,
password: true,
},
],
},
}}
>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('email password form')).not.toBeNull();
});
test('renders with sms passwordless as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{ ...mockSignInExperienceSettings, signIn: { methods: [smsSignInMethod] } }}
@ -58,6 +89,30 @@ describe('<SignIn />', () => {
expect(queryByText('action.continue')).not.toBeNull();
});
test('renders with phone password as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signIn: {
methods: [
{
...smsSignInMethod,
verificationCode: false,
password: true,
},
],
},
}}
>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('Phone password form')).not.toBeNull();
});
test('renders with social as primary', async () => {
const { container } = renderWithPageContext(
<SettingsProvider settings={{ ...mockSignInExperienceSettings, signIn: { methods: [] } }}>
@ -67,6 +122,8 @@ describe('<SignIn />', () => {
</SettingsProvider>
);
expect(container.querySelectorAll('button')).toHaveLength(defaultSize + 1); // Plus Expand Button
expect(container.querySelectorAll('button')).toHaveLength(
mockSignInExperienceSettings.socialConnectors.length
);
});
});

View file

@ -1,38 +1,50 @@
import { BrandingStyle } from '@logto/schemas';
import classNames from 'classnames';
import { useContext } from 'react';
import Divider from '@/components/Divider';
import TextLink from '@/components/TextLink';
import LandingPageContainer from '@/containers/LandingPageContainer';
import SignInMethodsLink from '@/containers/SignInMethodsLink';
import { SocialSignInList } from '@/containers/SocialSignIn';
import { useSieMethods } from '@/hooks/use-sie';
import BrandingHeader from '@/components/BrandingHeader';
import AppNotification from '@/containers/AppNotification';
import { PageContext } from '@/hooks/use-page-context';
import { getLogoUrl } from '@/utils/logo';
import MainForm from './MainForm';
import Main from './Main';
import * as styles from './index.module.scss';
const SignIn = () => {
const { experienceSettings, theme, platform } = useContext(PageContext);
if (!experienceSettings) {
return null;
}
const { slogan, logoUrl, darkLogoUrl, style } = experienceSettings.branding;
const { signInMethods, signUpMethods, socialConnectors } = useSieMethods();
const otherMethods = signInMethods.slice(1);
return (
<>
{platform === 'web' && <div className={styles.placeholderTop} />}
<div className={classNames(styles.wrapper)}>
<BrandingHeader
className={styles.header}
headline={style === BrandingStyle.Logo_Slogan ? slogan : undefined}
logo={getLogoUrl({ theme, logoUrl, darkLogoUrl })}
/>
<MainForm />
<AppNotification />
</div>
{platform === 'web' && <div className={styles.placeholderBottom} />}
</>
<LandingPageContainer>
<Main signInMethod={signInMethods[0]} socialConnectors={socialConnectors} />
{
// Other sign-in methods
otherMethods.length > 0 && (
<SignInMethodsLink signInMethods={otherMethods} template="sign_in_with" />
)
}
{
// Social sign-in methods
signInMethods.length > 0 && socialConnectors.length > 0 && (
<>
<Divider label="description.or" className={styles.divider} />
<SocialSignInList isCollapseEnabled socialConnectors={socialConnectors} />
</>
)
}
{
// Create Account footer
signUpMethods.length > 0 && (
<>
<div className={styles.placeHolder} />
<TextLink
replace
className={styles.createAccount}
to="/register"
text="action.create_account"
/>
</>
)
}
</LandingPageContainer>
);
};

View file

@ -47,3 +47,9 @@ export type PreviewConfig = {
platform: Platform;
isNative: boolean;
};
export type ArrayElement<ArrayType extends readonly unknown[]> = ArrayType extends ReadonlyArray<
infer ElementType
>
? ElementType
: never;

5
pnpm-lock.yaml generated
View file

@ -707,9 +707,8 @@ importers:
superstruct: ^0.16.0
typescript: ^4.7.4
use-debounced-loader: ^0.1.1
dependencies:
'@logto/core-kit': 1.0.0-beta.20
devDependencies:
'@logto/core-kit': 1.0.0-beta.20
'@logto/language-kit': 1.0.0-beta.20
'@logto/phrases': link:../phrases
'@logto/phrases-ui': link:../phrases-ui
@ -7494,7 +7493,7 @@ packages:
/history/5.3.0:
resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==}
dependencies:
'@babel/runtime': 7.18.3
'@babel/runtime': 7.19.4
dev: true
/hoist-non-react-statics/3.3.2: