diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 579f4a639..ef054fdad 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -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: '登录', diff --git a/packages/ui/src/__mocks__/logto.tsx b/packages/ui/src/__mocks__/logto.tsx index daaa5f940..8b7d6e5fa 100644 --- a/packages/ui/src/__mocks__/logto.tsx +++ b/packages/ui/src/__mocks__/logto.tsx @@ -77,4 +77,5 @@ export const mockSignInExperienceSettings: SignInExperienceSettings = { languageInfo: mockSignInExperience.languageInfo, primarySignInMethod: 'username', secondarySignInMethods: ['email', 'sms', 'social'], + socialConnectors, }; diff --git a/packages/ui/src/components/ConfirmModal/index.module.scss b/packages/ui/src/components/ConfirmModal/index.module.scss index 17361c06e..5c3f30035 100644 --- a/packages/ui/src/components/ConfirmModal/index.module.scss +++ b/packages/ui/src/components/ConfirmModal/index.module.scss @@ -1,5 +1,9 @@ @use '@/scss/underscore' as _; +.overlay { + z-index: 100; +} + .container { background: var(--color-base); padding: _.unit(6) _.unit(5); diff --git a/packages/ui/src/components/ConfirmModal/index.tsx b/packages/ui/src/components/ConfirmModal/index.tsx index bf229939c..3de9b6dab 100644 --- a/packages/ui/src/components/ConfirmModal/index.tsx +++ b/packages/ui/src/components/ConfirmModal/index.tsx @@ -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} > diff --git a/packages/ui/src/components/Input/phoneInput.module.scss b/packages/ui/src/components/Input/phoneInput.module.scss index cf6b2af90..53959d15b 100644 --- a/packages/ui/src/components/Input/phoneInput.module.scss +++ b/packages/ui/src/components/Input/phoneInput.module.scss @@ -8,6 +8,7 @@ width: auto; @include _.flex-row; position: relative; + margin-right: _.unit(1); > select { appearance: none; diff --git a/packages/ui/src/containers/Passwordless/index.module.scss b/packages/ui/src/containers/Passwordless/index.module.scss index fa9f4384c..a2fee9eef 100644 --- a/packages/ui/src/containers/Passwordless/index.module.scss +++ b/packages/ui/src/containers/Passwordless/index.module.scss @@ -11,10 +11,10 @@ } .inputField { - margin-bottom: _.unit(11); + margin-bottom: _.unit(12); } .terms { - margin-bottom: _.unit(6); + margin-bottom: _.unit(4); } } diff --git a/packages/ui/src/containers/SignInMethodsLink/index.tsx b/packages/ui/src/containers/SignInMethodsLink/index.tsx index 3980e86b7..21407464c 100644 --- a/packages/ui/src/containers/SignInMethodsLink/index.tsx +++ b/packages/ui/src/containers/SignInMethodsLink/index.tsx @@ -42,6 +42,10 @@ const SignInMethodsLink = ({ signInMethods, type = 'secondary', className }: Pro [navigate, signInMethods] ); + if (signInMethodsLink.length === 0) { + return null; + } + if (type === 'primary') { return
{signInMethodsLink}
; } @@ -58,13 +62,13 @@ const SignInMethodsLink = ({ signInMethods, type = 'secondary', className }: Pro rawText ); - return
{textLink}
; + return
{textLink}
; } const rawText = t('secondary.sign_in_with', { method: signInMethods[0] }); const textLink = reactStringReplace(rawText, signInMethods[0], () => signInMethodsLink[0]); - return
{textLink}
; + return
{textLink}
; }; export default SignInMethodsLink; diff --git a/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.test.tsx b/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.test.tsx index 49bfabef8..1088fc4de 100644 --- a/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.test.tsx +++ b/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.test.tsx @@ -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( - - - + const { container } = renderWithPageContext( + + + + + ); - expect(container.querySelectorAll('button')).toHaveLength(3); + expect(container.querySelectorAll('button')).toHaveLength(defaultSize); }); it('more than three connectors', () => { - const { container } = render( - - - + const { container } = renderWithPageContext( + + + + + ); - expect(container.querySelectorAll('button')).toHaveLength(3); + expect(container.querySelectorAll('button')).toHaveLength(defaultSize); const expandButton = container.querySelector('svg'); diff --git a/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.tsx b/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.tsx index 2a235d6e7..435120c8c 100644 --- a/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.tsx +++ b/packages/ui/src/containers/SocialSignIn/PrimarySocialSignIn.tsx @@ -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>; 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 (
diff --git a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx index 9eee590b6..e5e010458 100644 --- a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx +++ b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx @@ -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( - - - + const { container } = renderWithPageContext( + + + + + ); - expect(container.querySelectorAll('button')).toHaveLength(3); + expect(container.querySelectorAll('button')).toHaveLength(defaultSize - 1); }); it('more than four connectors', () => { - const { container } = render( - - - + const { container } = renderWithPageContext( + + + + + ); - 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( - - - + + + + + ); const socialButton = container.querySelector('button'); @@ -81,9 +100,17 @@ describe('SecondarySocialSignIn', () => { const connectors = socialConnectors.slice(0, 1); const { container } = renderWithPageContext( - - - + + + + + ); 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( - - - } - /> - - + + + + } /> + + + ); await waitFor(() => { diff --git a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx index e51fd652f..8e0bfbfd1 100644 --- a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx +++ b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx @@ -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>; - 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 ( -
- {displayConnectors.map((connector) => ( - { - void invokeSocialSignIn(connector.id); + <> +
+ {displayConnectors.map((connector) => ( + { + void invokeSocialSignIn(connector.id); + }} + /> + ))} + {isOverSize && ( + { + setShowModal(true); + }} + /> + )} +
+ {isOverSize && ( + { + setShowModal(false); }} /> - ))} - {isOverSize && ( - )} -
+ ); }; diff --git a/packages/ui/src/containers/SocialSignIn/SocialSignInPopUp.tsx b/packages/ui/src/containers/SocialSignIn/SocialSignInPopUp.tsx index 15581bd08..842574652 100644 --- a/packages/ui/src/containers/SocialSignIn/SocialSignInPopUp.tsx +++ b/packages/ui/src/containers/SocialSignIn/SocialSignInPopUp.tsx @@ -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>; }; -const SocialSignInPopUp = ({ isOpen = false, onClose, className, connectors }: Props) => ( +const SocialSignInPopUp = ({ isOpen = false, onClose, className }: Props) => ( - + ); diff --git a/packages/ui/src/containers/UsernameSignin/index.module.scss b/packages/ui/src/containers/UsernameSignin/index.module.scss index c2a2ee3bd..e6388d7cd 100644 --- a/packages/ui/src/containers/UsernameSignin/index.module.scss +++ b/packages/ui/src/containers/UsernameSignin/index.module.scss @@ -15,6 +15,6 @@ } .terms { - margin: _.unit(6) 0; + margin: _.unit(8) 0 _.unit(4); } } diff --git a/packages/ui/src/hooks/use-social.ts b/packages/ui/src/hooks/use-social.ts index 10a272944..c2cca5180 100644 --- a/packages/ui/src/hooks/use-social.ts +++ b/packages/ui/src/hooks/use-social.ts @@ -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, }; diff --git a/packages/ui/src/pages/SignIn/index.module.scss b/packages/ui/src/pages/SignIn/index.module.scss index f58a976f2..736f59b2a 100644 --- a/packages/ui/src/pages/SignIn/index.module.scss +++ b/packages/ui/src/pages/SignIn/index.module.scss @@ -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; diff --git a/packages/ui/src/pages/SignIn/index.test.tsx b/packages/ui/src/pages/SignIn/index.test.tsx index 679f708e5..c4691b31e 100644 --- a/packages/ui/src/pages/SignIn/index.test.tsx +++ b/packages/ui/src/pages/SignIn/index.test.tsx @@ -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('', () => { - test('renders without exploding', async () => { - const { queryByText } = render(); + test('renders with username as primary', async () => { + const { queryByText, container } = renderWithPageContext( + + + + + + ); + 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( + + + + + + ); + 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( + + + + + + ); + expect(container.querySelector('input[name="phone"]')).not.toBeNull(); + expect(queryByText('action.continue')).not.toBeNull(); + }); + + test('renders with social as primary', async () => { + const { container } = renderWithPageContext( + + + + + + ); + + expect(container.querySelectorAll('button')).toHaveLength(3); + }); }); diff --git a/packages/ui/src/pages/SignIn/index.tsx b/packages/ui/src/pages/SignIn/index.tsx index 6c1666e12..d3ef41a5c 100644 --- a/packages/ui/src/pages/SignIn/index.tsx +++ b/packages/ui/src/pages/SignIn/index.tsx @@ -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} /> - + + { + switch (signInMethod) { + case 'email': + return ; + case 'sms': + return ; + case 'username': + return ; + case 'social': + return ( + <> + + + + ); + 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 ( + <> + + + + ); + } + + return ( + <> + + {secondarySignInMethods.includes('social') && ( + <> + + + + )} + + ); +}; diff --git a/packages/ui/src/scss/modal.module.scss b/packages/ui/src/scss/modal.module.scss index 8ea3bcce8..0b6c0d95d 100644 --- a/packages/ui/src/scss/modal.module.scss +++ b/packages/ui/src/scss/modal.module.scss @@ -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 */ diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index 8448a09b5..1f143a471 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -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; + export type SignInExperienceSettings = { branding: Branding; languageInfo: LanguageInfo; termsOfUse: TermsOfUse; primarySignInMethod: SignInMethod; secondarySignInMethods: SignInMethod[]; + socialConnectors: ConnectorData[]; }; diff --git a/packages/ui/src/utils/sign-in-experience.ts b/packages/ui/src/utils/sign-in-experience.ts index 2e7f3f76a..2b386fd6a 100644 --- a/packages/ui/src/utils/sign-in-experience.ts +++ b/packages/ui/src/utils/sign-in-experience.ts @@ -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 termsOfUse, primarySignInMethod: getPrimarySignInMethod(signInMethods), secondarySignInMethods: getSecondarySignInMethods(signInMethods), + socialConnectors, // TODO: get values from api }; };