From 3184e367737bf123f4d600819ecf013c185ea8dc Mon Sep 17 00:00:00 2001 From: simeng-li Date: Tue, 7 Mar 2023 10:55:29 +0800 Subject: [PATCH] feat(ui): implement lite mode set password form (#3282) --- .changeset-staged/curly-planes-relax.md | 11 ++ .changeset-staged/cyan-fireants-worry.md | 20 +++ packages/core/src/routes/admin-user.test.ts | 4 +- packages/phrases-ui/src/locales/de.ts | 2 + packages/phrases-ui/src/locales/en.ts | 2 + packages/phrases-ui/src/locales/fr.ts | 2 + packages/phrases-ui/src/locales/ko.ts | 2 + packages/phrases-ui/src/locales/pt-br.ts | 2 + packages/phrases-ui/src/locales/pt-pt.ts | 2 + packages/phrases-ui/src/locales/ru.ts | 2 + packages/phrases-ui/src/locales/tr-tr.ts | 2 + packages/phrases-ui/src/locales/zh-cn.ts | 2 + packages/toolkit/core-kit/src/regex.ts | 2 +- .../PasswordInputField/index.test.tsx | 2 +- .../InputFields/PasswordInputField/index.tsx | 2 +- .../src/containers/SetPassword/Lite.test.tsx | 89 +++++++++++ .../ui/src/containers/SetPassword/Lite.tsx | 90 ++++++++++++ .../{index.test.tsx => SetPassword.test.tsx} | 48 ++++-- .../containers/SetPassword/SetPassword.tsx | 139 ++++++++++++++++++ .../ui/src/containers/SetPassword/index.tsx | 124 ++-------------- .../pages/Continue/SetPassword/index.test.tsx | 38 ++++- .../src/pages/Continue/SetPassword/index.tsx | 5 +- .../src/pages/RegisterPassword/index.test.tsx | 38 ++++- .../ui/src/pages/RegisterPassword/index.tsx | 5 +- .../ui/src/pages/ResetPassword/index.test.tsx | 6 +- packages/ui/src/pages/ResetPassword/index.tsx | 5 +- packages/ui/src/utils/form.test.ts | 57 +++++++ packages/ui/src/utils/form.ts | 26 ++++ 28 files changed, 581 insertions(+), 148 deletions(-) create mode 100644 .changeset-staged/curly-planes-relax.md create mode 100644 .changeset-staged/cyan-fireants-worry.md create mode 100644 packages/ui/src/containers/SetPassword/Lite.test.tsx create mode 100644 packages/ui/src/containers/SetPassword/Lite.tsx rename packages/ui/src/containers/SetPassword/{index.test.tsx => SetPassword.test.tsx} (77%) create mode 100644 packages/ui/src/containers/SetPassword/SetPassword.tsx create mode 100644 packages/ui/src/utils/form.test.ts diff --git a/.changeset-staged/curly-planes-relax.md b/.changeset-staged/curly-planes-relax.md new file mode 100644 index 000000000..3a7c5afe9 --- /dev/null +++ b/.changeset-staged/curly-planes-relax.md @@ -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. diff --git a/.changeset-staged/cyan-fireants-worry.md b/.changeset-staged/cyan-fireants-worry.md new file mode 100644 index 000000000..4460b9d1e --- /dev/null +++ b/.changeset-staged/cyan-fireants-worry.md @@ -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. diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts index 7979e5325..13cb0cde7 100644 --- a/packages/core/src/routes/admin-user.test.ts +++ b/packages/core/src/routes/admin-user.test.ts @@ -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) { diff --git a/packages/phrases-ui/src/locales/de.ts b/packages/phrases-ui/src/locales/de.ts index 698a076be..dc8c50070 100644 --- a/packages/phrases-ui/src/locales/de.ts +++ b/packages/phrases-ui/src/locales/de.ts @@ -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', diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index 98865c545..bd22b1d15 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -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', diff --git a/packages/phrases-ui/src/locales/fr.ts b/packages/phrases-ui/src/locales/fr.ts index 3ae761ba6..fd48d9918 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -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', diff --git a/packages/phrases-ui/src/locales/ko.ts b/packages/phrases-ui/src/locales/ko.ts index af1d9df32..2a5e6a406 100644 --- a/packages/phrases-ui/src/locales/ko.ts +++ b/packages/phrases-ui/src/locales/ko.ts @@ -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: '연동 정보가 유효하지 않아요.', diff --git a/packages/phrases-ui/src/locales/pt-br.ts b/packages/phrases-ui/src/locales/pt-br.ts index 6f4f3999a..c2106d2b0 100644 --- a/packages/phrases-ui/src/locales/pt-br.ts +++ b/packages/phrases-ui/src/locales/pt-br.ts @@ -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', diff --git a/packages/phrases-ui/src/locales/pt-pt.ts b/packages/phrases-ui/src/locales/pt-pt.ts index 29f415ba7..ade6f63cb 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -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', diff --git a/packages/phrases-ui/src/locales/ru.ts b/packages/phrases-ui/src/locales/ru.ts index 64bd714e0..56f207f41 100644 --- a/packages/phrases-ui/src/locales/ru.ts +++ b/packages/phrases-ui/src/locales/ru.ts @@ -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: 'Данные коннектора недействительны.', diff --git a/packages/phrases-ui/src/locales/tr-tr.ts b/packages/phrases-ui/src/locales/tr-tr.ts index 27af1db0a..65feb618b 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -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', diff --git a/packages/phrases-ui/src/locales/zh-cn.ts b/packages/phrases-ui/src/locales/zh-cn.ts index 375749d6c..d9425779a 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -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: '无效的登录请求', diff --git a/packages/toolkit/core-kit/src/regex.ts b/packages/toolkit/core-kit/src/regex.ts index 49daf6471..97f591122 100644 --- a/packages/toolkit/core-kit/src/regex.ts +++ b/packages/toolkit/core-kit/src/regex.ts @@ -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; diff --git a/packages/ui/src/components/InputFields/PasswordInputField/index.test.tsx b/packages/ui/src/components/InputFields/PasswordInputField/index.test.tsx index 2479d3692..d524e1e26 100644 --- a/packages/ui/src/components/InputFields/PasswordInputField/index.test.tsx +++ b/packages/ui/src/components/InputFields/PasswordInputField/index.test.tsx @@ -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'); } }); diff --git a/packages/ui/src/components/InputFields/PasswordInputField/index.tsx b/packages/ui/src/components/InputFields/PasswordInputField/index.tsx index e38c197d2..a5dd13bf2 100644 --- a/packages/ui/src/components/InputFields/PasswordInputField/index.tsx +++ b/packages/ui/src/components/InputFields/PasswordInputField/index.tsx @@ -26,8 +26,8 @@ const PasswordInputField = (props: Props, forwardRef: Ref { event.preventDefault(); + toggleShowPassword(); }} - onClick={toggleShowPassword} > {showPassword ? : } diff --git a/packages/ui/src/containers/SetPassword/Lite.test.tsx b/packages/ui/src/containers/SetPassword/Lite.test.tsx new file mode 100644 index 000000000..266309d99 --- /dev/null +++ b/packages/ui/src/containers/SetPassword/Lite.test.tsx @@ -0,0 +1,89 @@ +import { render, fireEvent, act, waitFor } from '@testing-library/react'; + +import Lite from './Lite'; + +describe('', () => { + const submit = jest.fn(); + const clearError = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('default render ', () => { + const { queryByText, container } = render(); + 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( + + ); + + 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(); + 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(); + 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'); + }); + }); +}); diff --git a/packages/ui/src/containers/SetPassword/Lite.tsx b/packages/ui/src/containers/SetPassword/Lite.tsx new file mode 100644 index 000000000..d5693904d --- /dev/null +++ b/packages/ui/src/containers/SetPassword/Lite.tsx @@ -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({ + reValidateMode: 'onBlur', + defaultValues: { newPassword: '' }, + }); + + useEffect(() => { + if (!isValid) { + clearErrorMessage?.(); + } + }, [clearErrorMessage, isValid]); + + const onSubmitHandler = useCallback( + (event?: React.FormEvent) => { + clearErrorMessage?.(); + + void handleSubmit((data, event) => { + onSubmit(data.newPassword); + })(event); + }, + [clearErrorMessage, handleSubmit, onSubmit] + ); + + return ( +
+ { + const errorMessage = validatePassword(password); + + if (errorMessage) { + return typeof errorMessage === 'string' + ? t(`error.${errorMessage}`) + : t(`error.${errorMessage.code}`, errorMessage.data); + } + + return true; + }, + })} + /> + + {errorMessage && {errorMessage}} + +