0
Fork 0
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:
simeng-li 2023-03-07 10:55:29 +08:00 committed by GitHub
parent c685558263
commit 3184e36773
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 581 additions and 148 deletions

View 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.

View 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.

View file

@ -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) {

View file

@ -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',

View file

@ -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 dont match. Please try again.',
invalid_passcode: 'The verification code is invalid',
invalid_connector_auth: 'The authorization is invalid',

View file

@ -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',

View file

@ -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: '연동 정보가 유효하지 않아요.',

View file

@ -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',

View file

@ -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',

View file

@ -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: 'Данные коннектора недействительны.',

View file

@ -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',

View file

@ -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: '无效的登录请求',

View file

@ -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;

View file

@ -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');
}
});

View file

@ -26,8 +26,8 @@ const PasswordInputField = (props: Props, forwardRef: Ref<Nullable<HTMLInputElem
<IconButton
onMouseDown={(event) => {
event.preventDefault();
toggleShowPassword();
}}
onClick={toggleShowPassword}
>
{showPassword ? <PasswordShowIcon /> : <PasswordHideIcon />}
</IconButton>

View 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');
});
});
});

View 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;

View file

@ -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');
});
});
});

View 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;

View file

@ -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} />
);
};

View file

@ -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!@#$' });
});
});
});

View file

@ -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>

View file

@ -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');
});
});
});

View file

@ -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

View file

@ -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!@#$');
});
});
});

View file

@ -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

View 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);
});
});
});

View file

@ -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: {