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

refactor(ui): add new PasswordRegisterWithUsernamePage (#2292)

This commit is contained in:
simeng-li 2022-11-02 11:28:06 +08:00 committed by GitHub
parent 386e021e76
commit 69f7534b32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 366 additions and 122 deletions

View file

@ -11,6 +11,7 @@ import Consent from './pages/Consent';
import ErrorPage from './pages/ErrorPage';
import ForgotPassword from './pages/ForgotPassword';
import Passcode from './pages/Passcode';
import PasswordRegisterWithUsername from './pages/PasswordRegisterWithUsername';
import Register from './pages/Register';
import ResetPassword from './pages/ResetPassword';
import SecondaryRegister from './pages/SecondaryRegister';
@ -68,8 +69,10 @@ const App = () => {
{/* register */}
<Route path="/register" element={<Register />} />
{/* TODO: @simeng LOG-4456 */}
<Route path="/register/password" element={null} />
<Route
path="/register/username/password"
element={<PasswordRegisterWithUsername />}
/>
<Route path="/register/:method" element={<SecondaryRegister />} />
{/* forgot password */}

View file

@ -1,40 +1,39 @@
import { fireEvent, act, waitFor } from '@testing-library/react';
import { render, fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { resetPassword } from '@/apis/forgot-password';
import SetPassword from '.';
import ResetPassword from '.';
describe('<SetPassword />', () => {
const submit = jest.fn();
const clearError = jest.fn();
const mockedNavigate = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/forgot-password', () => ({
resetPassword: jest.fn(async () => ({ redirectTo: '/' })),
}));
describe('<ResetPassword />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<ResetPassword />);
test('default render ', () => {
const { queryByText, container } = render(
<SetPassword errorMessage="error" onSubmit={submit} />
);
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(queryByText('error')).not.toBeNull();
expect(queryByText('action.save_password')).not.toBeNull();
});
test('password are required', () => {
const { queryByText, getByText } = renderWithPageContext(<ResetPassword />);
const { queryByText, getByText } = render(
<SetPassword clearErrorMessage={clearError} onSubmit={submit} />
);
const submitButton = getByText('action.save_password');
fireEvent.click(submitButton);
expect(clearError).toBeCalled();
expect(queryByText('password_required')).not.toBeNull();
expect(resetPassword).not.toBeCalled();
expect(submit).not.toBeCalled();
});
test('password less than 6 chars should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<ResetPassword />);
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
@ -48,7 +47,7 @@ describe('<ResetPassword />', () => {
expect(queryByText('password_min_length')).not.toBeNull();
expect(resetPassword).not.toBeCalled();
expect(submit).not.toBeCalled();
act(() => {
// Clear error
@ -61,7 +60,7 @@ describe('<ResetPassword />', () => {
});
test('password mismatch with confirmPassword should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<ResetPassword />);
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
@ -80,7 +79,7 @@ describe('<ResetPassword />', () => {
expect(queryByText('passwords_do_not_match')).not.toBeNull();
expect(resetPassword).not.toBeCalled();
expect(submit).not.toBeCalled();
act(() => {
// Clear Error
@ -93,7 +92,7 @@ describe('<ResetPassword />', () => {
});
test('should submit properly', async () => {
const { queryByText, getByText, container } = renderWithPageContext(<ResetPassword />);
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
@ -113,7 +112,7 @@ describe('<ResetPassword />', () => {
expect(queryByText('passwords_do_not_match')).toBeNull();
await waitFor(() => {
expect(resetPassword).toBeCalled();
expect(submit).toBeCalledWith('123456');
});
});
});

View file

@ -1,17 +1,11 @@
import classNames from 'classnames';
import { useEffect, useCallback, useMemo, useContext } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { resetPassword } from '@/apis/forgot-password';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context';
import { passwordValidation, confirmPasswordValidation } from '@/utils/field-validations';
import * as styles from './index.module.scss';
@ -20,6 +14,9 @@ type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
onSubmit: (password: string) => void;
errorMessage?: string;
clearErrorMessage?: () => void;
};
type FieldState = {
@ -32,61 +29,31 @@ const defaultState: FieldState = {
confirmPassword: '',
};
const ResetPassword = ({ className, autoFocus }: Props) => {
const SetPassword = ({
className,
autoFocus,
onSubmit,
errorMessage,
clearErrorMessage,
}: Props) => {
const { t } = useTranslation();
const { setToast } = useContext(PageContext);
const {
fieldValue,
formErrorMessage,
setFieldValue,
register,
validateForm,
setFormErrorMessage,
} = useForm(defaultState);
const { show } = useConfirmModal();
const navigate = useNavigate();
const resetPasswordErrorHandlers: ErrorHandlers = useMemo(
() => ({
'session.verification_session_not_found': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'session.verification_expired': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'user.same_password': (error) => {
setFormErrorMessage(error.message);
},
}),
[navigate, setFormErrorMessage, show]
);
const { result, run: asyncRegister } = useApi(resetPassword, resetPasswordErrorHandlers);
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
setFormErrorMessage(undefined);
clearErrorMessage?.();
if (!validateForm()) {
return;
}
void asyncRegister(fieldValue.password);
onSubmit(fieldValue.password);
},
[setFormErrorMessage, validateForm, asyncRegister, fieldValue.password]
[clearErrorMessage, validateForm, onSubmit, fieldValue.password]
);
useEffect(() => {
if (result) {
setToast(t('description.password_changed'));
navigate('/sign-in', { replace: true });
}
}, [navigate, result, setToast, t]);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
@ -115,9 +82,7 @@ const ResetPassword = ({ className, autoFocus }: Props) => {
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
}}
/>
{formErrorMessage && (
<ErrorMessage className={styles.formErrors}>{formErrorMessage}</ErrorMessage>
)}
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<Button title="action.save_password" onClick={async () => onSubmitHandler()} />
@ -126,4 +91,4 @@ const ResetPassword = ({ className, autoFocus }: Props) => {
);
};
export default ResetPassword;
export default SetPassword;

View file

@ -1,5 +1,6 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useEffect, useCallback, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@ -11,6 +12,7 @@ import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import useTerms from '@/hooks/use-terms';
import { UserFlow } from '@/types';
import { usernameValidation } from '@/utils/field-validations';
import * as styles from './index.module.scss';
@ -52,7 +54,7 @@ const UsernameRegister = ({ className }: Props) => {
[setFieldErrors]
);
const { result, run: asyncVerifyUsername } = useApi(verifyUsernameExistence, errorHandlers);
const { run: asyncVerifyUsername } = useApi(verifyUsernameExistence, errorHandlers);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
@ -66,16 +68,19 @@ const UsernameRegister = ({ className }: Props) => {
return;
}
void asyncVerifyUsername(fieldValue.username);
},
[validateForm, termsValidation, asyncVerifyUsername, fieldValue]
);
const { username } = fieldValue;
useEffect(() => {
if (result) {
navigate('/register/password');
}
}, [navigate, result]);
// Use sync call for this api to make sure the username value being passed to the password set page stays the same
const result = await asyncVerifyUsername(username);
if (result) {
navigate(`/${UserFlow.register}/${SignInIdentifier.Username}/password`, {
state: { username },
});
}
},
[validateForm, termsValidation, fieldValue, asyncVerifyUsername, navigate]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { signInBasic } from '@/apis/sign-in';
@ -36,22 +36,17 @@ const defaultState: FieldState = {
const UsernameSignIn = ({ className, autoFocus }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const {
fieldValue,
formErrorMessage,
setFieldValue,
register,
validateForm,
setFormErrorMessage,
} = useForm(defaultState);
const [errorMessage, setErrorMessage] = useState<string>();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'session.invalid_credentials': (error) => {
setFormErrorMessage(error.message);
setErrorMessage(error.message);
},
}),
[setFormErrorMessage]
[setErrorMessage]
);
const { result, run: asyncSignInBasic } = useApi(signInBasic, errorHandlers);
@ -60,7 +55,7 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
setFormErrorMessage(undefined);
setErrorMessage(undefined);
if (!validateForm()) {
return;
@ -74,14 +69,7 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
void asyncSignInBasic(fieldValue.username, fieldValue.password, socialToBind);
},
[
setFormErrorMessage,
validateForm,
termsValidation,
asyncSignInBasic,
fieldValue.username,
fieldValue.password,
]
[validateForm, termsValidation, asyncSignInBasic, fieldValue.username, fieldValue.password]
);
useEffect(() => {
@ -112,9 +100,7 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
placeholder={t('input.password')}
{...register('password', (value) => requiredValidation('password', value))}
/>
{formErrorMessage && (
<ErrorMessage className={styles.formErrors}>{formErrorMessage}</ErrorMessage>
)}
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
</div>
<TermsOfUse className={styles.terms} />

View file

@ -16,7 +16,6 @@ const useForm = <T extends Record<string, unknown>>(initialState: T) => {
const [fieldValue, setFieldValue] = useState<T>(initialState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
const [formErrorMessage, setFormErrorMessage] = useState<string>();
const fieldValidationsRef = useRef<FieldValidations>({});
@ -63,11 +62,9 @@ const useForm = <T extends Record<string, unknown>>(initialState: T) => {
return {
fieldValue,
fieldErrors,
formErrorMessage,
validateForm,
setFieldValue,
setFieldErrors,
setFormErrorMessage,
register,
};
};

View file

@ -0,0 +1,100 @@
import { SignInIdentifier } from '@logto/schemas';
import { act, waitFor, fireEvent } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { register } from '@/apis/register';
import PasswordRegisterWithUsername from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
useLocation: jest.fn(() => ({ state: { username: 'username' } })),
}));
jest.mock('@/apis/register', () => ({
register: jest.fn(async () => ({ redirectTo: '/' })),
}));
const useLocationMock = useLocation as jest.Mock;
describe('<PasswordRegisterWithUsername />', () => {
afterEach(() => {
jest.clearAllMocks();
useLocationMock.mockImplementation(() => ({ state: { username: 'username' } }));
});
it('render PasswordRegister page properly', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<PasswordRegisterWithUsername />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(queryByText('action.save_password')).not.toBeNull();
});
it('render without username state should return error', () => {
useLocationMock.mockImplementation(() => ({}));
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<PasswordRegisterWithUsername />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-password"]')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
it('render without username signUp method should return error', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: { ...mockSignInExperienceSettings.signUp, methods: [SignInIdentifier.Email] },
}}
>
<PasswordRegisterWithUsername />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-password"]')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
it('submit properly', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<PasswordRegisterWithUsername />
</SettingsProvider>
);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
act(() => {
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(register).toBeCalledWith('username', '123456');
});
});
});

View file

@ -0,0 +1,41 @@
import { SignInIdentifier } from '@logto/schemas';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import SetPassword from '@/containers/SetPassword';
import { useSieMethods } from '@/hooks/use-sie';
import { usernameGuard } from '@/types/guard';
import ErrorPage from '../ErrorPage';
import useUsernamePasswordRegister from './use-username-password-register';
const PasswordRegisterWithUsername = () => {
const { state } = useLocation();
const { signUpMethods } = useSieMethods();
const { register } = useUsernamePasswordRegister();
const hasUserName = is(state, usernameGuard);
if (!hasUserName) {
return <ErrorPage />;
}
if (!signUpMethods.includes(SignInIdentifier.Username)) {
return <ErrorPage />;
}
return (
<SecondaryPageWrapper title="description.new_password">
<SetPassword
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onSubmit={(password) => {
void register(state.username, password);
}}
/>
</SecondaryPageWrapper>
);
};
export default PasswordRegisterWithUsername;

View file

@ -0,0 +1,40 @@
import { useMemo, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { register } from '@/apis/register';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { PageContext } from '@/hooks/use-page-context';
const useUsernamePasswordRegister = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { setToast } = useContext(PageContext);
const { show } = useConfirmModal();
const resetPasswordErrorHandlers: ErrorHandlers = useMemo(
() => ({
'user.username_exists_register': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
}),
[navigate, show]
);
const { result, run: asyncRegister } = useApi(register, resetPasswordErrorHandlers);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [result, setToast, t]);
return {
register: asyncRegister,
};
};
export default useUsernamePasswordRegister;

View file

@ -1,11 +1,25 @@
import { render } from '@testing-library/react';
import { act, waitFor, fireEvent } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { resetPassword } from '@/apis/forgot-password';
import ResetPassword from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/forgot-password', () => ({
resetPassword: jest.fn(async () => ({ redirectTo: '/' })),
}));
describe('ForgotPassword', () => {
it('render forgot-password page properly', () => {
const { queryByText } = render(
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/forgot-password']}>
<Routes>
<Route path="/forgot-password" element={<ResetPassword />} />
@ -13,6 +27,31 @@ describe('ForgotPassword', () => {
</MemoryRouter>
);
expect(queryByText('description.new_password')).not.toBeNull();
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(queryByText('action.save_password')).not.toBeNull();
});
test('should submit properly', async () => {
const { getByText, container } = renderWithPageContext(<ResetPassword />);
const submitButton = getByText('action.save_password');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
act(() => {
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(resetPassword).toBeCalledWith('123456');
});
});
});

View file

@ -1,11 +1,20 @@
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import ResetPasswordForm from '@/containers/ResetPassword';
import SetPassword from '@/containers/SetPassword';
import useResetPassword from './use-reset-password';
const ResetPassword = () => {
const { resetPassword, errorMessage, clearErrorMessage } = useResetPassword();
return (
<SecondaryPageWrapper title="description.new_password">
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<ResetPasswordForm autoFocus />
<SetPassword
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
onSubmit={resetPassword}
/>
</SecondaryPageWrapper>
);
};

View file

@ -0,0 +1,56 @@
import { useMemo, useState, useContext, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { resetPassword } from '@/apis/forgot-password';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { PageContext } from '@/hooks/use-page-context';
const useResetPassword = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { setToast } = useContext(PageContext);
const { show } = useConfirmModal();
const [errorMessage, setErrorMessage] = useState<string>();
const clearErrorMessage = useCallback(() => {
// eslint-disable-next-line unicorn/no-useless-undefined
setErrorMessage(undefined);
}, []);
const resetPasswordErrorHandlers: ErrorHandlers = useMemo(
() => ({
'session.verification_session_not_found': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'session.verification_expired': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'user.same_password': (error) => {
setErrorMessage(error.message);
},
}),
[navigate, setErrorMessage, show]
);
const { result, run: asyncResetPassword } = useApi(resetPassword, resetPasswordErrorHandlers);
useEffect(() => {
if (result) {
setToast(t('description.password_changed'));
navigate('/sign-in', { replace: true });
}
}, [navigate, result, setToast, t]);
return {
resetPassword: asyncResetPassword,
errorMessage,
clearErrorMessage,
};
};
export default useResetPassword;

View file

@ -20,3 +20,7 @@ export const userFlowGuard = s.union([
s.literal('register'),
s.literal('forgot-password'),
]);
export const usernameGuard = s.object({
username: s.string(),
});