diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index f54db8e1b..bc643d3e7 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -18,7 +18,6 @@ import PasswordRegisterWithUsername from './pages/PasswordRegisterWithUsername'; 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 SignInPassword from './pages/SignInPassword'; import SocialLanding from './pages/SocialLanding'; @@ -63,52 +62,57 @@ const App = () => { }> - } /> - } /> + } /> + } /> + } /> + }> {/* Sign-in */} - : } - /> - } /> - } /> - } /> + + : } + /> + } /> + } /> + {/* Register */} - : } - /> - } - /> - } /> + + : } + /> + } /> + } /> + {/* Forgot password */} - } /> - } /> + + } /> + } /> + {/* Continue set up missing profile */} - } - /> - } /> + + } /> + } /> + + + {/* Passwordless verification code */} + } /> {/* Social sign-in pages */} - } /> - } /> - } /> - - {/* Always keep route path with param as the last one */} - } /> + + } /> + } /> + + } /> } /> diff --git a/packages/ui/src/__mocks__/logto.tsx b/packages/ui/src/__mocks__/logto.tsx index 433c4c502..5d2d1e382 100644 --- a/packages/ui/src/__mocks__/logto.tsx +++ b/packages/ui/src/__mocks__/logto.tsx @@ -1,4 +1,4 @@ -import type { SignInExperience } from '@logto/schemas'; +import type { SignInExperience, SignIn } from '@logto/schemas'; import { BrandingStyle, ConnectorPlatform, @@ -11,6 +11,7 @@ import type { SignInExperienceResponse } from '@/types'; export const appLogo = 'https://avatars.githubusercontent.com/u/88327661?s=200&v=4'; export const appHeadline = 'Build user identity in a modern way'; + export const socialConnectors = [ { id: 'BE8QXN0VsrOH7xdWFDJZ9', @@ -230,3 +231,58 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = { phone: true, }, }; + +const usernameSettings = { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, +}; + +export const mockSignInMethodSettingsTestCases: Array = [ + [ + usernameSettings, + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: true, + isPasswordPrimary: true, + }, + { + identifier: SignInIdentifier.Phone, + password: true, + verificationCode: true, + isPasswordPrimary: true, + }, + ], + [ + usernameSettings, + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: true, + isPasswordPrimary: false, + }, + { + identifier: SignInIdentifier.Phone, + password: true, + verificationCode: false, + isPasswordPrimary: false, + }, + ], + [ + usernameSettings, + { + identifier: SignInIdentifier.Email, + password: false, + verificationCode: true, + isPasswordPrimary: false, + }, + { + identifier: SignInIdentifier.Phone, + password: false, + verificationCode: true, + isPasswordPrimary: false, + }, + ], +]; diff --git a/packages/ui/src/apis/interaction.ts b/packages/ui/src/apis/interaction.ts index a3a3e0072..d70e24db4 100644 --- a/packages/ui/src/apis/interaction.ts +++ b/packages/ui/src/apis/interaction.ts @@ -61,7 +61,10 @@ export const setUserPassword = async (password: string) => { return result || { success: true }; }; -export type SendVerificationCodePayload = { email: string } | { phone: string }; +export type SendVerificationCodePayload = { + email?: string; + phone?: string; +}; export const putInteraction = async (event: InteractionEvent) => api.put(`${interactionPrefix}`, { json: { event } }); diff --git a/packages/ui/src/apis/utils.ts b/packages/ui/src/apis/utils.ts index 0da8d52e4..3f59a23eb 100644 --- a/packages/ui/src/apis/utils.ts +++ b/packages/ui/src/apis/utils.ts @@ -5,20 +5,22 @@ import { UserFlow } from '@/types'; import type { SendVerificationCodePayload } from './interaction'; import { putInteraction, sendVerificationCode } from './interaction'; -export const getSendVerificationCodeApi = - (type: UserFlow) => async (payload: SendVerificationCodePayload) => { - if (type === UserFlow.forgotPassword) { - await putInteraction(InteractionEvent.ForgotPassword); - } +/** Move to API */ +export const sendVerificationCodeApi = async ( + type: UserFlow, + payload: SendVerificationCodePayload +) => { + if (type === UserFlow.forgotPassword) { + await putInteraction(InteractionEvent.ForgotPassword); + } - // Init a new interaction only if the user is not binding with a social - if (type === UserFlow.signIn) { - await putInteraction(InteractionEvent.SignIn); - } + if (type === UserFlow.signIn) { + await putInteraction(InteractionEvent.SignIn); + } - if (type === UserFlow.register) { - await putInteraction(InteractionEvent.Register); - } + if (type === UserFlow.register) { + await putInteraction(InteractionEvent.Register); + } - return sendVerificationCode(payload); - }; + return sendVerificationCode(payload); +}; diff --git a/packages/ui/src/containers/EmailForm/EmailContinue.tsx b/packages/ui/src/containers/EmailForm/EmailContinue.tsx index b7039c456..25c2d4a26 100644 --- a/packages/ui/src/containers/EmailForm/EmailContinue.tsx +++ b/packages/ui/src/containers/EmailForm/EmailContinue.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import { is } from 'superstruct'; -import useSendVerificationCode from '@/hooks/use-send-verification-code'; +import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy'; import { UserFlow } from '@/types'; import { registeredSocialIdentityStateGuard } from '@/types/guard'; import { maskEmail } from '@/utils/format'; diff --git a/packages/ui/src/containers/EmailForm/EmailRegister.tsx b/packages/ui/src/containers/EmailForm/EmailRegister.tsx index 7740efbde..b4ba2ae4a 100644 --- a/packages/ui/src/containers/EmailForm/EmailRegister.tsx +++ b/packages/ui/src/containers/EmailForm/EmailRegister.tsx @@ -1,6 +1,6 @@ import { SignInIdentifier } from '@logto/schemas'; -import useSendVerificationCode from '@/hooks/use-send-verification-code'; +import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy'; import { UserFlow } from '@/types'; import EmailForm from './EmailForm'; diff --git a/packages/ui/src/containers/EmailForm/EmailResetPassword.tsx b/packages/ui/src/containers/EmailForm/EmailResetPassword.tsx index 4514f45d3..548609bc3 100644 --- a/packages/ui/src/containers/EmailForm/EmailResetPassword.tsx +++ b/packages/ui/src/containers/EmailForm/EmailResetPassword.tsx @@ -1,6 +1,6 @@ import { SignInIdentifier } from '@logto/schemas'; -import useSendVerificationCode from '@/hooks/use-send-verification-code'; +import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy'; import { UserFlow } from '@/types'; import EmailForm from './EmailForm'; diff --git a/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx b/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx deleted file mode 100644 index 4f6b41724..000000000 --- a/packages/ui/src/containers/EmailForm/EmailSignIn.test.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { InteractionEvent, SignInIdentifier } from '@logto/schemas'; -import { fireEvent, waitFor, act } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; - -import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; -import { sendVerificationCode, putInteraction } from '@/apis/interaction'; - -import EmailSignIn from './EmailSignIn'; - -const mockedNavigate = jest.fn(); - -jest.mock('@/apis/interaction', () => ({ - sendVerificationCode: jest.fn(() => ({ success: true })), - putInteraction: jest.fn(() => ({ success: true })), -})); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockedNavigate, -})); - -describe('EmailSignIn', () => { - const email = 'foo@logto.io'; - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('EmailSignIn form with password as primary method', async () => { - const { container, getByText } = renderWithPageContext( - - - - ); - const emailInput = container.querySelector('input[name="email"]'); - - if (emailInput) { - fireEvent.change(emailInput, { target: { value: email } }); - } - - const submitButton = getByText('action.sign_in'); - - act(() => { - fireEvent.click(submitButton); - }); - - await waitFor(() => { - expect(putInteraction).not.toBeCalled(); - expect(sendVerificationCode).not.toBeCalled(); - expect(mockedNavigate).toBeCalledWith( - { pathname: '/sign-in/email/password' }, - { state: { email } } - ); - }); - }); - - test('EmailSignIn form with password true but verification code false', async () => { - const { container, getByText } = renderWithPageContext( - - - - ); - const emailInput = container.querySelector('input[name="email"]'); - - if (emailInput) { - fireEvent.change(emailInput, { target: { value: email } }); - } - - const submitButton = getByText('action.sign_in'); - - act(() => { - fireEvent.click(submitButton); - }); - - await waitFor(() => { - expect(putInteraction).not.toBeCalled(); - expect(sendVerificationCode).not.toBeCalled(); - expect(mockedNavigate).toBeCalledWith( - { pathname: '/sign-in/email/password' }, - { state: { email } } - ); - }); - }); - - test('EmailSignIn form with password true but not primary, verification code true', async () => { - const { container, getByText } = renderWithPageContext( - - - - ); - - const emailInput = container.querySelector('input[name="email"]'); - - if (emailInput) { - fireEvent.change(emailInput, { target: { value: email } }); - } - - const submitButton = getByText('action.sign_in'); - - act(() => { - fireEvent.click(submitButton); - }); - - await waitFor(() => { - expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn); - expect(sendVerificationCode).toBeCalledWith({ email }); - expect(mockedNavigate).toBeCalledWith( - { pathname: '/sign-in/email/verification-code', search: '' }, - { state: { email } } - ); - }); - }); - - test('EmailSignIn form with password false but primary verification code true', async () => { - const { container, getByText } = renderWithPageContext( - - - - ); - - const emailInput = container.querySelector('input[name="email"]'); - - if (emailInput) { - fireEvent.change(emailInput, { target: { value: email } }); - } - - const submitButton = getByText('action.sign_in'); - - act(() => { - fireEvent.click(submitButton); - }); - - await waitFor(() => { - expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn); - expect(sendVerificationCode).toBeCalledWith({ email }); - expect(mockedNavigate).toBeCalledWith( - { pathname: '/sign-in/email/verification-code', search: '' }, - { state: { email } } - ); - }); - }); -}); diff --git a/packages/ui/src/containers/EmailForm/EmailSignIn.tsx b/packages/ui/src/containers/EmailForm/EmailSignIn.tsx deleted file mode 100644 index c6bf5a618..000000000 --- a/packages/ui/src/containers/EmailForm/EmailSignIn.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { SignIn } from '@logto/schemas'; -import { SignInIdentifier } from '@logto/schemas'; - -import useContinueSignInWithPassword from '@/hooks/use-continue-sign-in-with-password'; -import useSendVerificationCode from '@/hooks/use-send-verification-code'; -import type { ArrayElement } from '@/types'; -import { UserFlow } from '@/types'; - -import EmailForm from './EmailForm'; - -type FormProps = { - className?: string; - // eslint-disable-next-line react/boolean-prop-naming - autoFocus?: boolean; -}; - -type Props = FormProps & { - signInMethod: ArrayElement; -}; - -const EmailSignInWithVerificationCode = (props: FormProps) => { - const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode( - UserFlow.signIn, - SignInIdentifier.Email - ); - - return ( - - ); -}; - -const EmailSignInWithPassword = (props: FormProps) => { - const onSubmit = useContinueSignInWithPassword(SignInIdentifier.Email); - - return ; -}; - -const EmailSignIn = ({ signInMethod, ...props }: Props) => { - const { password, isPasswordPrimary, verificationCode } = signInMethod; - - // Continue with password - if (password && (isPasswordPrimary || !verificationCode)) { - return ; - } - - // Send verification code - if (verificationCode) { - return ; - } - - return null; -}; - -export default EmailSignIn; diff --git a/packages/ui/src/containers/EmailForm/index.tsx b/packages/ui/src/containers/EmailForm/index.tsx index 5e7fc372b..5512cb3cd 100644 --- a/packages/ui/src/containers/EmailForm/index.tsx +++ b/packages/ui/src/containers/EmailForm/index.tsx @@ -1,4 +1,3 @@ export { default as EmailRegister } from './EmailRegister'; -export { default as EmailSignIn } from './EmailSignIn'; export { default as EmailResetPassword } from './EmailResetPassword'; export { default as EmailContinue } from './EmailContinue'; diff --git a/packages/ui/src/containers/PasswordSignInForm/index.test.tsx b/packages/ui/src/containers/PasswordSignInForm/index.test.tsx deleted file mode 100644 index 76351ecf5..000000000 --- a/packages/ui/src/containers/PasswordSignInForm/index.test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { InteractionEvent, SignInIdentifier } from '@logto/schemas'; -import { fireEvent, waitFor, act } from '@testing-library/react'; - -import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; -import { - signInWithPasswordIdentifier, - putInteraction, - sendVerificationCode, -} from '@/apis/interaction'; -import { UserFlow } from '@/types'; - -import PasswordSignInForm from '.'; - -jest.mock('@/apis/interaction', () => ({ - signInWithPasswordIdentifier: jest.fn(() => ({ redirectTo: '/' })), - sendVerificationCode: jest.fn(() => ({ success: true })), - putInteraction: jest.fn(() => ({ success: true })), -})); - -const mockedNavigate = jest.fn(); - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockedNavigate, -})); - -describe('PasswordSignInForm', () => { - const email = 'foo@logto.io'; - const phone = '18573333333'; - const password = '111222'; - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('Password is required', async () => { - const { getByText, queryByText } = renderWithPageContext( - - ); - - const submitButton = getByText('action.continue'); - - act(() => { - fireEvent.click(submitButton); - }); - - await waitFor(() => { - expect(signInWithPasswordIdentifier).not.toBeCalled(); - expect(queryByText('password_required')).not.toBeNull(); - }); - }); - - it('EmailPasswordSignForm', async () => { - const { getByText, container } = renderWithPageContext( - - ); - - const passwordInput = container.querySelector('input[name="password"]'); - - if (passwordInput) { - fireEvent.change(passwordInput, { target: { value: password } }); - } - - const submitButton = getByText('action.continue'); - - act(() => { - fireEvent.click(submitButton); - }); - - await waitFor(() => { - expect(signInWithPasswordIdentifier).toBeCalledWith({ email, password }); - }); - - const sendVerificationCodeLink = getByText('action.sign_in_via_passcode'); - - expect(sendVerificationCodeLink).not.toBeNull(); - - act(() => { - fireEvent.click(sendVerificationCodeLink); - }); - - await waitFor(() => { - expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn); - expect(sendVerificationCode).toBeCalledWith({ email }); - }); - - expect(mockedNavigate).toBeCalledWith( - { - pathname: `/${UserFlow.signIn}/${SignInIdentifier.Email}/verification-code`, - search: '', - }, - { - state: { email }, - replace: true, - } - ); - }); - - it('PhonePasswordSignForm', async () => { - const { getByText, container } = renderWithPageContext( - - ); - - const passwordInput = container.querySelector('input[name="password"]'); - - if (passwordInput) { - fireEvent.change(passwordInput, { target: { value: password } }); - } - - const submitButton = getByText('action.continue'); - - act(() => { - fireEvent.click(submitButton); - }); - - await waitFor(() => { - expect(signInWithPasswordIdentifier).toBeCalledWith({ phone, password }); - }); - - const sendVerificationCodeLink = getByText('action.sign_in_via_passcode'); - - expect(sendVerificationCodeLink).not.toBeNull(); - - act(() => { - fireEvent.click(sendVerificationCodeLink); - }); - - await waitFor(() => { - expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn); - expect(sendVerificationCode).toBeCalledWith({ phone }); - }); - - expect(mockedNavigate).toBeCalledWith( - { - pathname: `/${UserFlow.signIn}/${SignInIdentifier.Phone}/verification-code`, - search: '', - }, - { - state: { phone }, - replace: true, - } - ); - }); -}); diff --git a/packages/ui/src/containers/PasswordSignInForm/index.tsx b/packages/ui/src/containers/PasswordSignInForm/index.tsx deleted file mode 100644 index b39db3da0..000000000 --- a/packages/ui/src/containers/PasswordSignInForm/index.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { SignInIdentifier } from '@logto/schemas'; -import classNames from 'classnames'; -import { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; - -import Button from '@/components/Button'; -import ErrorMessage from '@/components/ErrorMessage'; -import ForgotPasswordLink from '@/components/ForgotPasswordLink'; -import { PasswordInput } from '@/components/Input'; -import useForm from '@/hooks/use-form'; -import usePasswordSignIn from '@/hooks/use-password-sign-in'; -import { useForgotPasswordSettings } from '@/hooks/use-sie'; -import { requiredValidation } from '@/utils/field-validations'; - -import PasswordlessSignInLink from './PasswordlessSignInLink'; -import * as styles from './index.module.scss'; - -type Props = { - method: SignInIdentifier.Email | SignInIdentifier.Phone; - value: string; - hasPasswordlessButton?: boolean; - className?: string; - // eslint-disable-next-line react/boolean-prop-naming - autoFocus?: boolean; -}; - -type FieldState = { - password: string; -}; - -const defaultState: FieldState = { - password: '', -}; - -const PasswordSignInForm = ({ - className, - autoFocus, - hasPasswordlessButton = false, - method, - value, -}: Props) => { - const { t } = useTranslation(); - const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(); - const { fieldValue, register, validateForm } = useForm(defaultState); - const { isForgotPasswordEnabled, phone, email } = useForgotPasswordSettings(); - - const onSubmitHandler = useCallback( - async (event?: React.FormEvent) => { - event?.preventDefault(); - - clearErrorMessage(); - - if (!validateForm()) { - return; - } - - const payload = - method === SignInIdentifier.Email - ? { email: value, password: fieldValue.password } - : { phone: value, password: fieldValue.password }; - - void onSubmit(payload); - }, - [clearErrorMessage, validateForm, onSubmit, method, value, fieldValue.password] - ); - - return ( -
- requiredValidation('password', value))} - /> - {errorMessage && {errorMessage}} - - {isForgotPasswordEnabled && ( - - )} - -