mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(ui): implement lite mode set password form (#3282)
This commit is contained in:
parent
c685558263
commit
3184e36773
28 changed files with 581 additions and 148 deletions
11
.changeset-staged/curly-planes-relax.md
Normal file
11
.changeset-staged/curly-planes-relax.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
"@logto/ui": patch
|
||||
---
|
||||
|
||||
## Implement a lite version of set password form.
|
||||
|
||||
To simplify the effort when user set new password, we implement a lite version of set password form.
|
||||
|
||||
The lite version of set password form only contains only one field password. It will be used if and only if the forgot-password feature is enabled (password can be reset either by email and phone).
|
||||
|
||||
If you do not have any email or sms service enabled, we still use the old version of set password form which contains two fields: password and confirm password.
|
20
.changeset-staged/cyan-fireants-worry.md
Normal file
20
.changeset-staged/cyan-fireants-worry.md
Normal file
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
"@logto/phrases-ui": minor
|
||||
"@logto/ui": minor
|
||||
---
|
||||
|
||||
### Update the password policy
|
||||
|
||||
Password policy description: Password requires a minimum of 8 characters and contains a mix of letters, numbers, and symbols.
|
||||
|
||||
- min-length updates: Password requires a minimum of 8 characters
|
||||
- allowed characters updates: Password contains a mix of letters, numbers, and symbols
|
||||
- digits: 0-9
|
||||
- letters: a-z, A-Z
|
||||
- symbols: !"#$%&'()\*+,./:;<=>?@[\]^\_`{|}~-
|
||||
- At least two types of characters are required:
|
||||
- letters and digits
|
||||
- letters and symbols
|
||||
- digits and symbols
|
||||
|
||||
> notice: The new password policy is applied to new users or new passwords only. Existing users are not affected by this change, users may still use their old password to sign-in.
|
|
@ -316,7 +316,7 @@ describe('adminUserRoutes', () => {
|
|||
|
||||
it('PATCH /users/:userId/password', async () => {
|
||||
const mockedUserId = 'foo';
|
||||
const password = '123456';
|
||||
const password = '1234asd$';
|
||||
const response = await userRequest.patch(`/users/${mockedUserId}/password`).send({ password });
|
||||
expect(encryptUserPassword).toHaveBeenCalledWith(password);
|
||||
expect(findUserById).toHaveBeenCalledTimes(1);
|
||||
|
@ -328,7 +328,7 @@ describe('adminUserRoutes', () => {
|
|||
|
||||
it('PATCH /users/:userId/password should throw if user cannot be found', async () => {
|
||||
const notExistedUserId = 'notExistedUserId';
|
||||
const dummyPassword = '123456';
|
||||
const dummyPassword = '1234asd$';
|
||||
|
||||
findUserById.mockImplementationOnce(async (userId) => {
|
||||
if (userId === notExistedUserId) {
|
||||
|
|
|
@ -107,6 +107,8 @@ const translation = {
|
|||
invalid_phone: 'Die Telefonnummer ist ungültig',
|
||||
password_min_length: 'Passwort muss mindestens {{min}} Zeichen lang sein',
|
||||
passwords_do_not_match: 'Passwörter stimmen nicht überein',
|
||||
invalid_password:
|
||||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
|
||||
invalid_passcode: 'Der Bestätigungscode ist ungültig',
|
||||
invalid_connector_auth: 'Die Autorisierung ist ungültig',
|
||||
invalid_connector_request: 'Connector Daten sind ungültig',
|
||||
|
|
|
@ -102,6 +102,8 @@ const translation = {
|
|||
invalid_email: 'The email is invalid',
|
||||
invalid_phone: 'The phone number is invalid',
|
||||
password_min_length: 'Password requires a minimum of {{min}} characters',
|
||||
invalid_password:
|
||||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.',
|
||||
passwords_do_not_match: 'Your passwords don’t match. Please try again.',
|
||||
invalid_passcode: 'The verification code is invalid',
|
||||
invalid_connector_auth: 'The authorization is invalid',
|
||||
|
|
|
@ -108,6 +108,8 @@ const translation = {
|
|||
invalid_phone: "Le numéro de téléphone n'est pas valide",
|
||||
password_min_length: 'Le mot de passe doit comporter un minimum de {{min}} caractères.',
|
||||
passwords_do_not_match: 'Les mots de passe ne correspondent pas',
|
||||
invalid_password:
|
||||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
|
||||
invalid_passcode: 'Le code est invalide',
|
||||
invalid_connector_auth: "L'autorisation n'est pas valide",
|
||||
invalid_connector_request: 'Les données du connecteur ne sont pas valides',
|
||||
|
|
|
@ -101,6 +101,8 @@ const translation = {
|
|||
invalid_phone: '휴대전화번호가 유효하지 않아요.',
|
||||
password_min_length: '비밀번호는 최소 {{min}} 자리로 이루어져야 해요.',
|
||||
passwords_do_not_match: '비밀번호가 일치하지 않아요.',
|
||||
invalid_password:
|
||||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
|
||||
invalid_passcode: '비밀번호가 유효하지 않아요.',
|
||||
invalid_connector_auth: '인증이 유효하지 않아요.',
|
||||
invalid_connector_request: '연동 정보가 유효하지 않아요.',
|
||||
|
|
|
@ -105,6 +105,8 @@ const translation = {
|
|||
invalid_phone: 'O número de telefone é inválido',
|
||||
password_min_length: 'A senha requer um mínimo de {{min}} caracteres',
|
||||
passwords_do_not_match: 'Suas senhas não correspondem. Por favor, tente novamente.',
|
||||
invalid_password:
|
||||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
|
||||
invalid_passcode: 'O código de verificação é inválido',
|
||||
invalid_connector_auth: 'A autorização é inválida',
|
||||
invalid_connector_request: 'Os dados do conector são inválidos',
|
||||
|
|
|
@ -103,6 +103,8 @@ const translation = {
|
|||
invalid_email: 'O email é inválido',
|
||||
invalid_phone: 'O número de telefone é inválido',
|
||||
password_min_length: 'A password requer um mínimo de {{min}} caracteres',
|
||||
invalid_password:
|
||||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
|
||||
passwords_do_not_match: 'As passwords não coincidem',
|
||||
invalid_passcode: 'A senha é inválida',
|
||||
invalid_connector_auth: 'A autorização é inválida',
|
||||
|
|
|
@ -106,6 +106,8 @@ const translation = {
|
|||
invalid_phone: 'Номер телефона указан неправильно',
|
||||
password_min_length: 'Пароль должен быть минимум {{min}} символов',
|
||||
passwords_do_not_match: 'Пароли не совпадают. Пожалуйста, попробуйте еще раз.',
|
||||
invalid_password:
|
||||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
|
||||
invalid_passcode: 'Неправильный код подтверждения',
|
||||
invalid_connector_auth: 'Авторизация недействительна',
|
||||
invalid_connector_request: 'Данные коннектора недействительны.',
|
||||
|
|
|
@ -104,6 +104,8 @@ const translation = {
|
|||
invalid_phone: 'Telefon numarası geçersiz',
|
||||
password_min_length: 'Şifre en az {{min}} karakterden oluşmalıdır',
|
||||
passwords_do_not_match: 'Şifreler eşleşmiyor',
|
||||
invalid_password:
|
||||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
|
||||
invalid_passcode: 'Kod geçersiz',
|
||||
invalid_connector_auth: 'Yetki geçersiz',
|
||||
invalid_connector_request: 'Bağlayıcı veri geçersiz',
|
||||
|
|
|
@ -100,6 +100,8 @@ const translation = {
|
|||
invalid_phone: '无效的手机号',
|
||||
password_min_length: '密码最少需要{{min}}个字符',
|
||||
passwords_do_not_match: '两次输入的密码不一致,请重试。',
|
||||
invalid_password:
|
||||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
|
||||
invalid_passcode: '无效的验证码',
|
||||
invalid_connector_auth: '登录失败',
|
||||
invalid_connector_request: '无效的登录请求',
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
export const emailRegEx = /^\S+@\S+\.\S+$/;
|
||||
export const phoneRegEx = /^\d+$/;
|
||||
export const usernameRegEx = /^[A-Z_a-z]\w*$/;
|
||||
export const passwordRegEx = /^.{6,}$/;
|
||||
export const passwordRegEx = /^[\w!"#$%&'()*+,./:;<=>?@[\]^`{|}~-]{8,}$/;
|
||||
export const webRedirectUriProtocolRegEx = /^https?:$/;
|
||||
export const mobileUriSchemeProtocolRegEx = /^[a-z][\d_a-z]*(\.[\d_a-z]+)+:$/;
|
||||
export const hexColorRegEx = /^#[\da-f]{3}([\da-f]{3})?$/i;
|
||||
|
|
|
@ -52,7 +52,7 @@ describe('Input Field UI Component', () => {
|
|||
expect(visibilityButton).not.toBeNull();
|
||||
|
||||
if (visibilityButton) {
|
||||
fireEvent.click(visibilityButton);
|
||||
fireEvent.mouseDown(visibilityButton);
|
||||
expect(inputElement.type).toEqual('text');
|
||||
}
|
||||
});
|
||||
|
|
|
@ -26,8 +26,8 @@ const PasswordInputField = (props: Props, forwardRef: Ref<Nullable<HTMLInputElem
|
|||
<IconButton
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
toggleShowPassword();
|
||||
}}
|
||||
onClick={toggleShowPassword}
|
||||
>
|
||||
{showPassword ? <PasswordShowIcon /> : <PasswordHideIcon />}
|
||||
</IconButton>
|
||||
|
|
89
packages/ui/src/containers/SetPassword/Lite.test.tsx
Normal file
89
packages/ui/src/containers/SetPassword/Lite.test.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import { render, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
|
||||
import Lite from './Lite';
|
||||
|
||||
describe('<Lite />', () => {
|
||||
const submit = jest.fn();
|
||||
const clearError = jest.fn();
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('default render ', () => {
|
||||
const { queryByText, container } = render(<Lite errorMessage="error" onSubmit={submit} />);
|
||||
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||
expect(queryByText('error')).not.toBeNull();
|
||||
expect(queryByText('action.save_password')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('password is required', async () => {
|
||||
const { queryByText, getByText } = render(
|
||||
<Lite clearErrorMessage={clearError} onSubmit={submit} />
|
||||
);
|
||||
|
||||
const submitButton = getByText('action.save_password');
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
expect(clearError).toBeCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.password_required')).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(submit).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('password less than 8 chars should throw', async () => {
|
||||
const { queryByText, getByText, container } = render(<Lite onSubmit={submit} />);
|
||||
const submitButton = getByText('action.save_password');
|
||||
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '1234567' } });
|
||||
}
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.password_min_length')).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(submit).not.toBeCalled();
|
||||
|
||||
act(() => {
|
||||
// Clear error
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '1234asdf' } });
|
||||
fireEvent.blur(passwordInput);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.password_min_length')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('should submit properly', async () => {
|
||||
const { queryByText, getByText, container } = render(<Lite onSubmit={submit} />);
|
||||
const submitButton = getByText('action.save_password');
|
||||
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||
|
||||
act(() => {
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '1234asdf' } });
|
||||
}
|
||||
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submit).toBeCalledWith('1234asdf');
|
||||
});
|
||||
});
|
||||
});
|
90
packages/ui/src/containers/SetPassword/Lite.tsx
Normal file
90
packages/ui/src/containers/SetPassword/Lite.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import { PasswordInputField } from '@/components/InputFields';
|
||||
import { validatePassword } from '@/utils/form';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
onSubmit: (password: string) => void;
|
||||
errorMessage?: string;
|
||||
clearErrorMessage?: () => void;
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
const Lite = ({ className, autoFocus, onSubmit, errorMessage, clearErrorMessage }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<FieldState>({
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: { newPassword: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValid) {
|
||||
clearErrorMessage?.();
|
||||
}
|
||||
}, [clearErrorMessage, isValid]);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
(event?: React.FormEvent<HTMLFormElement>) => {
|
||||
clearErrorMessage?.();
|
||||
|
||||
void handleSubmit((data, event) => {
|
||||
onSubmit(data.newPassword);
|
||||
})(event);
|
||||
},
|
||||
[clearErrorMessage, handleSubmit, onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<PasswordInputField
|
||||
className={styles.inputField}
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.password')}
|
||||
autoFocus={autoFocus}
|
||||
isDanger={!!errors.newPassword}
|
||||
errorMessage={errors.newPassword?.message}
|
||||
aria-invalid={!!errors.newPassword}
|
||||
{...register('newPassword', {
|
||||
required: t('error.password_required'),
|
||||
validate: (password) => {
|
||||
const errorMessage = validatePassword(password);
|
||||
|
||||
if (errorMessage) {
|
||||
return typeof errorMessage === 'string'
|
||||
? t(`error.${errorMessage}`)
|
||||
: t(`error.${errorMessage.code}`, errorMessage.data);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
<Button name="submit" title="action.save_password" htmlType="submit" />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Lite;
|
|
@ -1,6 +1,6 @@
|
|||
import { render, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
|
||||
import SetPassword from '.';
|
||||
import SetPassword from './SetPassword';
|
||||
|
||||
describe('<SetPassword />', () => {
|
||||
const submit = jest.fn();
|
||||
|
@ -40,7 +40,7 @@ describe('<SetPassword />', () => {
|
|||
expect(submit).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('password less than 6 chars should throw', async () => {
|
||||
test('password less than 8 chars should throw', async () => {
|
||||
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
|
||||
const submitButton = getByText('action.save_password');
|
||||
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||
|
@ -62,7 +62,7 @@ describe('<SetPassword />', () => {
|
|||
act(() => {
|
||||
// Clear error
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(passwordInput, { target: { value: '1234asdf' } });
|
||||
fireEvent.blur(passwordInput);
|
||||
}
|
||||
});
|
||||
|
@ -72,6 +72,36 @@ describe('<SetPassword />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('password with single type chars should throw', async () => {
|
||||
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
|
||||
const submitButton = getByText('action.save_password');
|
||||
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '12345678' } });
|
||||
}
|
||||
|
||||
act(() => {
|
||||
fireEvent.submit(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.invalid_password')).not.toBeNull();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// Clear error
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '1234asdf' } });
|
||||
fireEvent.blur(passwordInput);
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('error.invalid_password')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('password mismatch with confirmPassword should throw', async () => {
|
||||
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
|
||||
const submitButton = getByText('action.save_password');
|
||||
|
@ -80,11 +110,11 @@ describe('<SetPassword />', () => {
|
|||
|
||||
act(() => {
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(passwordInput, { target: { value: '1234asdf' } });
|
||||
}
|
||||
|
||||
if (confirmPasswordInput) {
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '012345' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '0234asdf' } });
|
||||
}
|
||||
|
||||
fireEvent.submit(submitButton);
|
||||
|
@ -99,7 +129,7 @@ describe('<SetPassword />', () => {
|
|||
act(() => {
|
||||
// Clear Error
|
||||
if (confirmPasswordInput) {
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '1234asdf' } });
|
||||
fireEvent.blur(confirmPasswordInput);
|
||||
}
|
||||
});
|
||||
|
@ -117,11 +147,11 @@ describe('<SetPassword />', () => {
|
|||
|
||||
act(() => {
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(passwordInput, { target: { value: '1234asdf' } });
|
||||
}
|
||||
|
||||
if (confirmPasswordInput) {
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '1234asdf' } });
|
||||
}
|
||||
|
||||
fireEvent.submit(submitButton);
|
||||
|
@ -130,7 +160,7 @@ describe('<SetPassword />', () => {
|
|||
expect(queryByText('error.passwords_do_not_match')).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submit).toBeCalledWith('123456');
|
||||
expect(submit).toBeCalledWith('1234asdf');
|
||||
});
|
||||
});
|
||||
});
|
139
packages/ui/src/containers/SetPassword/SetPassword.tsx
Normal file
139
packages/ui/src/containers/SetPassword/SetPassword.tsx
Normal file
|
@ -0,0 +1,139 @@
|
|||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ClearIcon from '@/assets/icons/clear-icon.svg';
|
||||
import Button from '@/components/Button';
|
||||
import IconButton from '@/components/Button/IconButton';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import { InputField } from '@/components/InputFields';
|
||||
import { validatePassword } from '@/utils/form';
|
||||
|
||||
import TogglePassword from './TogglePassword';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
onSubmit: (password: string) => void;
|
||||
errorMessage?: string;
|
||||
clearErrorMessage?: () => void;
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
const SetPassword = ({
|
||||
className,
|
||||
autoFocus,
|
||||
onSubmit,
|
||||
errorMessage,
|
||||
clearErrorMessage,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
resetField,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<FieldState>({
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: { newPassword: '', confirmPassword: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValid) {
|
||||
clearErrorMessage?.();
|
||||
}
|
||||
}, [clearErrorMessage, isValid]);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
(event?: React.FormEvent<HTMLFormElement>) => {
|
||||
clearErrorMessage?.();
|
||||
|
||||
void handleSubmit((data, event) => {
|
||||
onSubmit(data.newPassword);
|
||||
})(event);
|
||||
},
|
||||
[clearErrorMessage, handleSubmit, onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<InputField
|
||||
className={styles.inputField}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.password')}
|
||||
autoFocus={autoFocus}
|
||||
isDanger={!!errors.newPassword}
|
||||
errorMessage={errors.newPassword?.message}
|
||||
aria-invalid={!!errors.newPassword}
|
||||
{...register('newPassword', {
|
||||
required: t('error.password_required'),
|
||||
validate: (password) => {
|
||||
const errorMessage = validatePassword(password);
|
||||
|
||||
if (errorMessage) {
|
||||
return typeof errorMessage === 'string'
|
||||
? t(`error.${errorMessage}`)
|
||||
: t(`error.${errorMessage.code}`, errorMessage.data);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
isSuffixFocusVisible={!!watch('newPassword')}
|
||||
suffix={
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
resetField('newPassword');
|
||||
}}
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
className={styles.inputField}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.confirm_password')}
|
||||
errorMessage={errors.confirmPassword?.message}
|
||||
aria-invalid={!!errors.confirmPassword}
|
||||
{...register('confirmPassword', {
|
||||
validate: (value) => value === watch('newPassword') || t('error.passwords_do_not_match'),
|
||||
})}
|
||||
isSuffixFocusVisible={!!watch('confirmPassword')}
|
||||
suffix={
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
resetField('confirmPassword');
|
||||
}}
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
<TogglePassword isChecked={showPassword} onChange={setShowPassword} />
|
||||
|
||||
<Button name="submit" title="action.save_password" htmlType="submit" />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetPassword;
|
|
@ -1,16 +1,7 @@
|
|||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForgotPasswordSettings } from '@/hooks/use-sie';
|
||||
|
||||
import ClearIcon from '@/assets/icons/clear-icon.svg';
|
||||
import Button from '@/components/Button';
|
||||
import IconButton from '@/components/Button/IconButton';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import { InputField } from '@/components/InputFields';
|
||||
|
||||
import TogglePassword from './TogglePassword';
|
||||
import * as styles from './index.module.scss';
|
||||
import SetPasswordLite from './Lite';
|
||||
import SetPasswordStandard from './SetPassword';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
|
@ -21,110 +12,13 @@ type Props = {
|
|||
clearErrorMessage?: () => void;
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
const SetPassword = (props: Props) => {
|
||||
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
|
||||
|
||||
const SetPassword = ({
|
||||
className,
|
||||
autoFocus,
|
||||
onSubmit,
|
||||
errorMessage,
|
||||
clearErrorMessage,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
resetField,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<FieldState>({
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: { newPassword: '', confirmPassword: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValid) {
|
||||
clearErrorMessage?.();
|
||||
}
|
||||
}, [clearErrorMessage, isValid]);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
(event?: React.FormEvent<HTMLFormElement>) => {
|
||||
clearErrorMessage?.();
|
||||
|
||||
void handleSubmit((data, event) => {
|
||||
onSubmit(data.newPassword);
|
||||
})(event);
|
||||
},
|
||||
[clearErrorMessage, handleSubmit, onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<InputField
|
||||
className={styles.inputField}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.password')}
|
||||
autoFocus={autoFocus}
|
||||
isDanger={!!errors.newPassword}
|
||||
errorMessage={errors.newPassword?.message}
|
||||
aria-invalid={!!errors.newPassword}
|
||||
{...register('newPassword', {
|
||||
required: t('error.password_required'),
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t('error.password_min_length', { min: 6 }),
|
||||
},
|
||||
})}
|
||||
isSuffixFocusVisible={!!watch('newPassword')}
|
||||
suffix={
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
resetField('newPassword');
|
||||
}}
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
className={styles.inputField}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.confirm_password')}
|
||||
errorMessage={errors.confirmPassword?.message}
|
||||
aria-invalid={!!errors.confirmPassword}
|
||||
{...register('confirmPassword', {
|
||||
validate: (value) => value === watch('newPassword') || t('error.passwords_do_not_match'),
|
||||
})}
|
||||
isSuffixFocusVisible={!!watch('confirmPassword')}
|
||||
suffix={
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
resetField('confirmPassword');
|
||||
}}
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||
|
||||
<TogglePassword isChecked={showPassword} onChange={setShowPassword} />
|
||||
|
||||
<Button name="submit" title="action.save_password" htmlType="submit" />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
return isForgotPasswordEnabled ? (
|
||||
<SetPasswordLite {...props} />
|
||||
) : (
|
||||
<SetPasswordStandard {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { act, waitFor, fireEvent } from '@testing-library/react';
|
|||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
import { addProfile } from '@/apis/interaction';
|
||||
|
||||
import SetPassword from '.';
|
||||
|
@ -18,20 +19,47 @@ jest.mock('@/apis/interaction', () => ({
|
|||
}));
|
||||
|
||||
describe('SetPassword', () => {
|
||||
it('render set-password page properly', () => {
|
||||
it('render set-password page properly without confirm password field', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SetPassword />
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="confirmPassword"]')).toBeNull();
|
||||
expect(queryByText('action.save_password')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('render set-password page properly with confirm password field', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
forgotPassword: {
|
||||
email: false,
|
||||
phone: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SetPassword />
|
||||
</SettingsProvider>
|
||||
);
|
||||
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
|
||||
expect(queryByText('action.save_password')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should submit properly', async () => {
|
||||
const { getByText, container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
forgotPassword: {
|
||||
email: false,
|
||||
phone: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SetPassword />
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
@ -41,18 +69,18 @@ describe('SetPassword', () => {
|
|||
|
||||
act(() => {
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(passwordInput, { target: { value: '1234!@#$' } });
|
||||
}
|
||||
|
||||
if (confirmPasswordInput) {
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '1234!@#$' } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfile).toBeCalledWith({ password: '123456' });
|
||||
expect(addProfile).toBeCalledWith({ password: '1234!@#$' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
|
||||
import SetPasswordForm from '@/containers/SetPassword';
|
||||
import { passwordMinLength } from '@/utils/form';
|
||||
|
||||
import useSetPassword from './use-set-password';
|
||||
|
||||
|
@ -9,8 +10,8 @@ const SetPassword = () => {
|
|||
return (
|
||||
<SecondaryPageWrapper
|
||||
title="description.set_password"
|
||||
description="error.password_min_length"
|
||||
descriptionProps={{ min: 6 }}
|
||||
description="error.invalid_password"
|
||||
descriptionProps={{ min: passwordMinLength }}
|
||||
>
|
||||
<SetPasswordForm autoFocus onSubmit={setPassword} />
|
||||
</SecondaryPageWrapper>
|
||||
|
|
|
@ -29,13 +29,33 @@ describe('<RegisterPassword />', () => {
|
|||
useLocationMock.mockImplementation(() => ({ state: { username: 'username' } }));
|
||||
});
|
||||
|
||||
it('render PasswordRegister page properly', () => {
|
||||
it('render PasswordRegister page without confirm password input field properly', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<RegisterPassword />
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="confirmPassword"]')).toBeNull();
|
||||
expect(queryByText('action.save_password')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('render PasswordRegister page with confirm password input field properly with forgot password disabled', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
forgotPassword: {
|
||||
email: false,
|
||||
phone: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RegisterPassword />
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
||||
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
|
||||
expect(queryByText('action.save_password')).not.toBeNull();
|
||||
|
@ -62,7 +82,15 @@ describe('<RegisterPassword />', () => {
|
|||
|
||||
it('submit properly', async () => {
|
||||
const { getByText, container } = renderWithPageContext(
|
||||
<SettingsProvider>
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
forgotPassword: {
|
||||
email: false,
|
||||
phone: false,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RegisterPassword />
|
||||
</SettingsProvider>
|
||||
);
|
||||
|
@ -73,18 +101,18 @@ describe('<RegisterPassword />', () => {
|
|||
|
||||
act(() => {
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(passwordInput, { target: { value: '1234asdf' } });
|
||||
}
|
||||
|
||||
if (confirmPasswordInput) {
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '1234asdf' } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setUserPassword).toBeCalledWith('123456');
|
||||
expect(setUserPassword).toBeCalledWith('1234asdf');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,6 +3,7 @@ import { SignInIdentifier } from '@logto/schemas';
|
|||
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
|
||||
import SetPassword from '@/containers/SetPassword';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import { passwordMinLength } from '@/utils/form';
|
||||
|
||||
import ErrorPage from '../ErrorPage';
|
||||
import useUsernamePasswordRegister from './use-username-password-register';
|
||||
|
@ -18,8 +19,8 @@ const RegisterPassword = () => {
|
|||
return (
|
||||
<SecondaryPageWrapper
|
||||
title="description.new_password"
|
||||
description="error.password_min_length"
|
||||
descriptionProps={{ min: 6 }}
|
||||
description="error.invalid_password"
|
||||
descriptionProps={{ min: passwordMinLength }}
|
||||
>
|
||||
<SetPassword
|
||||
autoFocus
|
||||
|
|
|
@ -40,18 +40,18 @@ describe('ForgotPassword', () => {
|
|||
|
||||
act(() => {
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(passwordInput, { target: { value: '1234!@#$' } });
|
||||
}
|
||||
|
||||
if (confirmPasswordInput) {
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '1234!@#$' } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setUserPassword).toBeCalledWith('123456');
|
||||
expect(setUserPassword).toBeCalledWith('1234!@#$');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
|
||||
import SetPassword from '@/containers/SetPassword';
|
||||
import { passwordMinLength } from '@/utils/form';
|
||||
|
||||
import useResetPassword from './use-reset-password';
|
||||
|
||||
|
@ -9,8 +10,8 @@ const ResetPassword = () => {
|
|||
return (
|
||||
<SecondaryPageWrapper
|
||||
title="description.new_password"
|
||||
description="error.password_min_length"
|
||||
descriptionProps={{ min: 6 }}
|
||||
description="error.invalid_password"
|
||||
descriptionProps={{ min: passwordMinLength }}
|
||||
>
|
||||
<SetPassword
|
||||
autoFocus
|
||||
|
|
57
packages/ui/src/utils/form.test.ts
Normal file
57
packages/ui/src/utils/form.test.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { validatePassword } from './form';
|
||||
|
||||
describe('password format', () => {
|
||||
it('password min length should be 8', () => {
|
||||
expect(validatePassword('a1?')).toEqual({ code: 'password_min_length', data: { min: 8 } });
|
||||
expect(validatePassword('aaa123aa')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('password should not contains non ASCII visible chars', () => {
|
||||
expect(validatePassword('a1?aaaaa测试')).toEqual({
|
||||
code: 'invalid_password',
|
||||
data: { min: 8 },
|
||||
});
|
||||
|
||||
expect(validatePassword('a1?aaaaa测试')).toEqual({
|
||||
code: 'invalid_password',
|
||||
data: { min: 8 },
|
||||
});
|
||||
|
||||
expect(validatePassword('a1?aaaaa🌹')).toEqual({
|
||||
code: 'invalid_password',
|
||||
data: { min: 8 },
|
||||
});
|
||||
|
||||
expect(validatePassword('a1?aaaaa')).toBe(undefined);
|
||||
});
|
||||
|
||||
describe('password should contains at least 2 of 3 types of chars', () => {
|
||||
const singleTypeChars = ['aaaaaaaa', '11111111', '!@#$%^&*(())'];
|
||||
|
||||
it.each(singleTypeChars)('single typed password format %p should be invalid', (password) => {
|
||||
expect(validatePassword(password)).toEqual({
|
||||
code: 'invalid_password',
|
||||
data: { min: 8 },
|
||||
});
|
||||
});
|
||||
|
||||
const doubleTypeChars = [
|
||||
'asdfghj1',
|
||||
'asdfghj$',
|
||||
'1234567@',
|
||||
'1234567a',
|
||||
'!@#$%^&1',
|
||||
'!@#$%^&a',
|
||||
];
|
||||
|
||||
it.each(doubleTypeChars)('double typed password format %p should be valid', (password) => {
|
||||
expect(validatePassword(password)).toBe(undefined);
|
||||
});
|
||||
|
||||
const tripleTypeChars = ['ASD!@#45', 'a!@#$%123', '1ASDfg654', '*123345GHJ'];
|
||||
|
||||
it.each(tripleTypeChars)('triple typed password format %p should be valid', (password) => {
|
||||
expect(validatePassword(password)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -12,6 +12,13 @@ const { t } = i18next;
|
|||
const usernameRegex = /^[A-Z_a-z-][\w-]*$/;
|
||||
const emailRegex = /^\S+@\S+\.\S+$/;
|
||||
|
||||
const specialChars = /[!"#$%&'()*+,./:;<=>?@[\]^_`{|}~-]/;
|
||||
const digits = /\d/;
|
||||
const letters = /[A-Za-z]/;
|
||||
const allowedChars = /^[\w!"#$%&'()*+,./:;<=>?@[\]^`{|}~-]*$/;
|
||||
|
||||
export const passwordMinLength = 8;
|
||||
|
||||
export const validateUsername = (username: string): ErrorType | undefined => {
|
||||
if (!username) {
|
||||
return 'username_required';
|
||||
|
@ -48,6 +55,25 @@ export const validatePhone = (value: string): ErrorType | undefined => {
|
|||
}
|
||||
};
|
||||
|
||||
export const validatePassword = (value: string): ErrorType | undefined => {
|
||||
const hasDigits = digits.test(value);
|
||||
const hasLetters = letters.test(value);
|
||||
const hasSpecialChars = specialChars.test(value);
|
||||
const nonInvalidChars = allowedChars.test(value);
|
||||
|
||||
if (!nonInvalidChars) {
|
||||
return { code: 'invalid_password', data: { min: passwordMinLength } };
|
||||
}
|
||||
|
||||
if (value.length < passwordMinLength) {
|
||||
return { code: 'password_min_length', data: { min: passwordMinLength } };
|
||||
}
|
||||
|
||||
if ((hasDigits ? 1 : 0) + (hasLetters ? 1 : 0) + (hasSpecialChars ? 1 : 0) < 2) {
|
||||
return { code: 'invalid_password', data: { min: passwordMinLength } };
|
||||
}
|
||||
};
|
||||
|
||||
export const validateIdentifierField = (type: IdentifierInputType, value: string) => {
|
||||
switch (type) {
|
||||
case SignInIdentifier.Username: {
|
||||
|
|
Loading…
Reference in a new issue