mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(ui): update set password form (#3020)
This commit is contained in:
parent
4a407ad4de
commit
4a84162722
23 changed files with 336 additions and 73 deletions
|
@ -35,9 +35,9 @@ describe('smoke testing', () => {
|
||||||
|
|
||||||
expect(page.url()).toBe(new URL('register/username/password', logtoUrl).href);
|
expect(page.url()).toBe(new URL('register/username/password', logtoUrl).href);
|
||||||
|
|
||||||
const passwordField = await page.waitForSelector('input[name=new-password]');
|
const passwordField = await page.waitForSelector('input[name=newPassword]');
|
||||||
const confirmPasswordField = await page.waitForSelector('input[name=confirm-new-password]');
|
const confirmPasswordField = await page.waitForSelector('input[name=confirmPassword]');
|
||||||
const saveButton = await page.waitForSelector('button');
|
const saveButton = await page.waitForSelector('button[name=submit]');
|
||||||
await passwordField.type(consolePassword);
|
await passwordField.type(consolePassword);
|
||||||
await confirmPasswordField.type(consolePassword);
|
await confirmPasswordField.type(consolePassword);
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,7 @@ const translation = {
|
||||||
link_another_email: 'Link another email', // UNTRANSLATED
|
link_another_email: 'Link another email', // UNTRANSLATED
|
||||||
link_another_phone: 'Link another phone', // UNTRANSLATED
|
link_another_phone: 'Link another phone', // UNTRANSLATED
|
||||||
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
||||||
|
show_password: 'Show password', // UNTRANSLATED
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
|
|
|
@ -36,6 +36,7 @@ const translation = {
|
||||||
link_another_email: 'Link another email',
|
link_another_email: 'Link another email',
|
||||||
link_another_phone: 'Link another phone',
|
link_another_phone: 'Link another phone',
|
||||||
link_another_email_or_phone: 'Link another email or phone',
|
link_another_email_or_phone: 'Link another email or phone',
|
||||||
|
show_password: 'Show password',
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
email: 'email',
|
email: 'email',
|
||||||
|
|
|
@ -38,6 +38,7 @@ const translation = {
|
||||||
link_another_email: 'Link another email', // UNTRANSLATED
|
link_another_email: 'Link another email', // UNTRANSLATED
|
||||||
link_another_phone: 'Link another phone', // UNTRANSLATED
|
link_another_phone: 'Link another phone', // UNTRANSLATED
|
||||||
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
||||||
|
show_password: 'Show password', // UNTRANSLATED
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
email: 'email',
|
email: 'email',
|
||||||
|
|
|
@ -38,6 +38,7 @@ const translation = {
|
||||||
link_another_email: 'Link another email', // UNTRANSLATED
|
link_another_email: 'Link another email', // UNTRANSLATED
|
||||||
link_another_phone: 'Link another phone', // UNTRANSLATED
|
link_another_phone: 'Link another phone', // UNTRANSLATED
|
||||||
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
||||||
|
show_password: 'Show password', // UNTRANSLATED
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
email: '이메일',
|
email: '이메일',
|
||||||
|
|
|
@ -38,6 +38,7 @@ const translation = {
|
||||||
link_another_email: 'Link another email', // UNTRANSLATED
|
link_another_email: 'Link another email', // UNTRANSLATED
|
||||||
link_another_phone: 'Link another phone', // UNTRANSLATED
|
link_another_phone: 'Link another phone', // UNTRANSLATED
|
||||||
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
||||||
|
show_password: 'Show password', // UNTRANSLATED
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
email: 'e-mail',
|
email: 'e-mail',
|
||||||
|
|
|
@ -38,6 +38,7 @@ const translation = {
|
||||||
link_another_email: 'Link another email', // UNTRANSLATED
|
link_another_email: 'Link another email', // UNTRANSLATED
|
||||||
link_another_phone: 'Link another phone', // UNTRANSLATED
|
link_another_phone: 'Link another phone', // UNTRANSLATED
|
||||||
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
||||||
|
show_password: 'Show password', // UNTRANSLATED
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
email: 'email',
|
email: 'email',
|
||||||
|
|
|
@ -38,6 +38,7 @@ const translation = {
|
||||||
link_another_email: 'Link another email', // UNTRANSLATED
|
link_another_email: 'Link another email', // UNTRANSLATED
|
||||||
link_another_phone: 'Link another phone', // UNTRANSLATED
|
link_another_phone: 'Link another phone', // UNTRANSLATED
|
||||||
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
||||||
|
show_password: 'Show password', // UNTRANSLATED
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
email: 'e-posta adresi',
|
email: 'e-posta adresi',
|
||||||
|
|
|
@ -38,6 +38,7 @@ const translation = {
|
||||||
link_another_email: 'Link another email', // UNTRANSLATED
|
link_another_email: 'Link another email', // UNTRANSLATED
|
||||||
link_another_phone: 'Link another phone', // UNTRANSLATED
|
link_another_phone: 'Link another phone', // UNTRANSLATED
|
||||||
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
link_another_email_or_phone: 'Link another email or phone', // UNTRANSLATED
|
||||||
|
show_password: 'Show password', // UNTRANSLATED
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
|
|
|
@ -61,6 +61,7 @@
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
"react-device-detect": "^2.2.2",
|
"react-device-detect": "^2.2.2",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
|
"react-hook-form": "^7.34.0",
|
||||||
"react-i18next": "^11.18.3",
|
"react-i18next": "^11.18.3",
|
||||||
"react-modal": "^3.15.1",
|
"react-modal": "^3.15.1",
|
||||||
"react-router-dom": "^6.2.2",
|
"react-router-dom": "^6.2.2",
|
||||||
|
|
90
packages/ui/src/components/InputField/index.module.scss
Normal file
90
packages/ui/src/components/InputField/index.module.scss
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
|
.inputField {
|
||||||
|
position: relative;
|
||||||
|
@include _.flex-row;
|
||||||
|
border: _.border(var(--color-line-border));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
transition-property: outline, border;
|
||||||
|
transition-timing-function: ease-in-out;
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
|
||||||
|
// fix in safari input field line-height issue
|
||||||
|
height: 44px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 0 _.unit(4);
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
caret-color: var(--color-brand-default);
|
||||||
|
font: var(--font-body-1);
|
||||||
|
color: var(--color-type-primary);
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-type-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
padding-right: _.unit(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.suffix {
|
||||||
|
position: absolute;
|
||||||
|
right: _.unit(4);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: _.unit(8);
|
||||||
|
height: _.unit(8);
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border: _.border(var(--color-brand-default));
|
||||||
|
|
||||||
|
input {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focusVisible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
border: _.border(var(--color-danger-default));
|
||||||
|
|
||||||
|
input {
|
||||||
|
caret-color: var(--color-danger-default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage {
|
||||||
|
margin-top: _.unit(1);
|
||||||
|
margin-left: _.unit(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.desktop) {
|
||||||
|
.inputField {
|
||||||
|
outline: 3px solid transparent;
|
||||||
|
|
||||||
|
input {
|
||||||
|
font: var(--font-body-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
outline-color: var(--color-overlay-brand-focused);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger:focus-within {
|
||||||
|
outline-color: var(--color-overlay-danger-focused);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
packages/ui/src/components/InputField/index.tsx
Normal file
40
packages/ui/src/components/InputField/index.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import type { ForwardedRef, HTMLProps, ReactElement } from 'react';
|
||||||
|
import { forwardRef, cloneElement } from 'react';
|
||||||
|
|
||||||
|
import type { ErrorType } from '@/components/ErrorMessage';
|
||||||
|
import ErrorMessage from '@/components/ErrorMessage';
|
||||||
|
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
type Props = HTMLProps<HTMLInputElement> & {
|
||||||
|
className?: string;
|
||||||
|
error?: ErrorType;
|
||||||
|
isDanger?: boolean;
|
||||||
|
suffix?: ReactElement;
|
||||||
|
isSuffixFocusVisible?: boolean;
|
||||||
|
isSuffixVisible?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputField = (
|
||||||
|
{ className, error, isDanger, suffix, isSuffixFocusVisible, isSuffixVisible, ...props }: Props,
|
||||||
|
reference: ForwardedRef<HTMLInputElement>
|
||||||
|
) => (
|
||||||
|
<div className={className}>
|
||||||
|
<div className={classNames(styles.inputField, isDanger && styles.danger)}>
|
||||||
|
<input {...props} ref={reference} />
|
||||||
|
{suffix &&
|
||||||
|
cloneElement(suffix, {
|
||||||
|
className: classNames([
|
||||||
|
suffix.props.className,
|
||||||
|
styles.suffix,
|
||||||
|
isSuffixFocusVisible && styles.focusVisible,
|
||||||
|
isSuffixVisible && styles.visible,
|
||||||
|
]),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default forwardRef(InputField);
|
|
@ -7,7 +7,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkBox {
|
.checkbox {
|
||||||
margin-right: _.unit(2);
|
margin-right: _.unit(2);
|
||||||
fill: var(--color-type-secondary);
|
fill: var(--color-type-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
@ -40,7 +40,7 @@ const TermsOfUse = ({ name, className, termsUrl, isChecked, onChange, onTermsCli
|
||||||
' ': toggle,
|
' ': toggle,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Checkbox name={name} checked={isChecked} className={styles.checkBox} />
|
<Checkbox name={name} checked={isChecked} className={styles.checkbox} />
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{prefix}
|
{prefix}
|
||||||
<TextLink
|
<TextLink
|
||||||
|
|
41
packages/ui/src/containers/SetPassword/TogglePassword.tsx
Normal file
41
packages/ui/src/containers/SetPassword/TogglePassword.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import Checkbox from '@/components/Checkbox';
|
||||||
|
import { onKeyDownHandler } from '@/utils/a11y';
|
||||||
|
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isChecked?: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TogglePassword = ({ isChecked, onChange }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
onChange(!isChecked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isChecked}
|
||||||
|
tabIndex={0}
|
||||||
|
className={styles.passwordSwitch}
|
||||||
|
onClick={toggle}
|
||||||
|
onKeyDown={onKeyDownHandler({
|
||||||
|
Escape: () => {
|
||||||
|
onChange(false);
|
||||||
|
},
|
||||||
|
Enter: toggle,
|
||||||
|
' ': toggle,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Checkbox name="toggle-password" checked={isChecked} className={styles.checkbox} />
|
||||||
|
<div>{t('action.show_password')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TogglePassword;
|
|
@ -8,7 +8,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.inputField,
|
.inputField,
|
||||||
.formErrors {
|
.formErrors,
|
||||||
|
.passwordSwitch {
|
||||||
margin-bottom: _.unit(4);
|
margin-bottom: _.unit(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,3 +18,16 @@
|
||||||
margin-top: _.unit(-3);
|
margin-top: _.unit(-3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.passwordSwitch {
|
||||||
|
@include _.flex-row;
|
||||||
|
width: 100%;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
margin-right: _.unit(2);
|
||||||
|
fill: var(--color-type-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,28 +14,36 @@ describe('<SetPassword />', () => {
|
||||||
const { queryByText, container } = render(
|
const { queryByText, container } = render(
|
||||||
<SetPassword errorMessage="error" onSubmit={submit} />
|
<SetPassword errorMessage="error" onSubmit={submit} />
|
||||||
);
|
);
|
||||||
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
|
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||||
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
|
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
|
||||||
expect(queryByText('error')).not.toBeNull();
|
expect(queryByText('error')).not.toBeNull();
|
||||||
expect(queryByText('action.save_password')).not.toBeNull();
|
expect(queryByText('action.save_password')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('password are required', () => {
|
test('password is required', async () => {
|
||||||
const { queryByText, getByText } = render(
|
const { queryByText, getByText } = render(
|
||||||
<SetPassword clearErrorMessage={clearError} onSubmit={submit} />
|
<SetPassword clearErrorMessage={clearError} onSubmit={submit} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitButton = getByText('action.save_password');
|
const submitButton = getByText('action.save_password');
|
||||||
fireEvent.click(submitButton);
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
expect(clearError).toBeCalled();
|
expect(clearError).toBeCalled();
|
||||||
expect(queryByText('password_required')).not.toBeNull();
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByText('password_required')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
expect(submit).not.toBeCalled();
|
expect(submit).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('password less than 6 chars should throw', () => {
|
test('password less than 6 chars should throw', async () => {
|
||||||
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
|
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
|
||||||
const submitButton = getByText('action.save_password');
|
const submitButton = getByText('action.save_password');
|
||||||
const passwordInput = container.querySelector('input[name="new-password"]');
|
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||||
|
|
||||||
if (passwordInput) {
|
if (passwordInput) {
|
||||||
fireEvent.change(passwordInput, { target: { value: '12345' } });
|
fireEvent.change(passwordInput, { target: { value: '12345' } });
|
||||||
|
@ -45,7 +53,9 @@ describe('<SetPassword />', () => {
|
||||||
fireEvent.click(submitButton);
|
fireEvent.click(submitButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(queryByText('password_min_length')).not.toBeNull();
|
await waitFor(() => {
|
||||||
|
expect(queryByText('password_min_length')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
expect(submit).not.toBeCalled();
|
expect(submit).not.toBeCalled();
|
||||||
|
|
||||||
|
@ -56,14 +66,16 @@ describe('<SetPassword />', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(queryByText('password_min_length')).toBeNull();
|
await waitFor(() => {
|
||||||
|
expect(queryByText('password_min_length')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('password mismatch with confirmPassword should throw', () => {
|
test('password mismatch with confirmPassword should throw', async () => {
|
||||||
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
|
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
|
||||||
const submitButton = getByText('action.save_password');
|
const submitButton = getByText('action.save_password');
|
||||||
const passwordInput = container.querySelector('input[name="new-password"]');
|
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||||
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
|
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
if (passwordInput) {
|
if (passwordInput) {
|
||||||
|
@ -77,7 +89,9 @@ describe('<SetPassword />', () => {
|
||||||
fireEvent.click(submitButton);
|
fireEvent.click(submitButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(queryByText('passwords_do_not_match')).not.toBeNull();
|
await waitFor(() => {
|
||||||
|
expect(queryByText('passwords_do_not_match')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
expect(submit).not.toBeCalled();
|
expect(submit).not.toBeCalled();
|
||||||
|
|
||||||
|
@ -88,14 +102,16 @@ describe('<SetPassword />', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(queryByText('passwords_do_not_match')).toBeNull();
|
await waitFor(() => {
|
||||||
|
expect(queryByText('passwords_do_not_match')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should submit properly', async () => {
|
test('should submit properly', async () => {
|
||||||
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
|
const { queryByText, getByText, container } = render(<SetPassword onSubmit={submit} />);
|
||||||
const submitButton = getByText('action.save_password');
|
const submitButton = getByText('action.save_password');
|
||||||
const passwordInput = container.querySelector('input[name="new-password"]');
|
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||||
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
|
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
if (passwordInput) {
|
if (passwordInput) {
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import ClearIcon from '@/assets/icons/clear-icon.svg';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
|
import IconButton from '@/components/Button/IconButton';
|
||||||
import ErrorMessage from '@/components/ErrorMessage';
|
import ErrorMessage from '@/components/ErrorMessage';
|
||||||
import Input from '@/components/Input';
|
import InputField from '@/components/InputField';
|
||||||
import useForm from '@/hooks/use-form';
|
import { passwordErrorWatcher } from '@/utils/form';
|
||||||
import { passwordValidation, confirmPasswordValidation } from '@/utils/field-validations';
|
|
||||||
|
|
||||||
|
import TogglePassword from './TogglePassword';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -20,15 +23,10 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type FieldState = {
|
type FieldState = {
|
||||||
password: string;
|
newPassword: string;
|
||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: FieldState = {
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SetPassword = ({
|
const SetPassword = ({
|
||||||
className,
|
className,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
|
@ -37,53 +35,93 @@ const SetPassword = ({
|
||||||
clearErrorMessage,
|
clearErrorMessage,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
watch,
|
||||||
|
resetField,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FieldState>({
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
defaultValues: { newPassword: '', confirmPassword: '' },
|
||||||
|
});
|
||||||
|
|
||||||
const onSubmitHandler = useCallback(
|
const onSubmitHandler = useCallback(
|
||||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
(event?: React.FormEvent<HTMLFormElement>) => {
|
||||||
event?.preventDefault();
|
|
||||||
|
|
||||||
clearErrorMessage?.();
|
clearErrorMessage?.();
|
||||||
|
|
||||||
if (!validateForm()) {
|
void handleSubmit((data, event) => {
|
||||||
return;
|
onSubmit(data.newPassword);
|
||||||
}
|
event?.preventDefault();
|
||||||
|
})(event);
|
||||||
onSubmit(fieldValue.password);
|
|
||||||
},
|
},
|
||||||
[clearErrorMessage, validateForm, onSubmit, fieldValue.password]
|
[clearErrorMessage, handleSubmit, onSubmit]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const newPasswordError = passwordErrorWatcher(errors.newPassword);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||||
<Input
|
<InputField
|
||||||
|
required
|
||||||
className={styles.inputField}
|
className={styles.inputField}
|
||||||
name="new-password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder={t('input.password')}
|
placeholder={t('input.password')}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
{...register('password', passwordValidation)}
|
isDanger={!!newPasswordError}
|
||||||
onClear={() => {
|
error={newPasswordError}
|
||||||
setFieldValue((state) => ({ ...state, password: '' }));
|
aria-invalid={!!newPasswordError}
|
||||||
}}
|
{...register('newPassword', { required: true, minLength: 6 })}
|
||||||
|
isSuffixFocusVisible={!!watch('newPassword')}
|
||||||
|
suffix={
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
resetField('newPassword');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
|
<InputField
|
||||||
|
required
|
||||||
className={styles.inputField}
|
className={styles.inputField}
|
||||||
name="confirm-new-password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
type="password"
|
autoComplete="new-password"
|
||||||
placeholder={t('input.confirm_password')}
|
placeholder={t('input.confirm_password')}
|
||||||
{...register('confirmPassword', (confirmPassword) =>
|
error={errors.confirmPassword && 'passwords_do_not_match'}
|
||||||
confirmPasswordValidation(fieldValue.password, confirmPassword)
|
aria-invalid={!!errors.confirmPassword}
|
||||||
)}
|
{...register('confirmPassword', {
|
||||||
isErrorStyling={false}
|
validate: (value) => value === watch('newPassword'),
|
||||||
onClear={() => {
|
})}
|
||||||
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
|
isSuffixFocusVisible={!!watch('confirmPassword')}
|
||||||
}}
|
suffix={
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
resetField('confirmPassword');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
|
||||||
|
|
||||||
<Button title="action.save_password" onClick={async () => onSubmitHandler()} />
|
<TogglePassword isChecked={showPassword} onChange={setShowPassword} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
name="submit"
|
||||||
|
title="action.save_password"
|
||||||
|
onClick={async () => {
|
||||||
|
onSubmitHandler();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<input hidden type="submit" />
|
<input hidden type="submit" />
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -24,8 +24,8 @@ describe('SetPassword', () => {
|
||||||
<SetPassword />
|
<SetPassword />
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
|
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||||
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
|
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
|
||||||
expect(queryByText('action.save_password')).not.toBeNull();
|
expect(queryByText('action.save_password')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -36,8 +36,8 @@ describe('SetPassword', () => {
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
const submitButton = getByText('action.save_password');
|
const submitButton = getByText('action.save_password');
|
||||||
const passwordInput = container.querySelector('input[name="new-password"]');
|
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||||
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
|
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
if (passwordInput) {
|
if (passwordInput) {
|
||||||
|
|
|
@ -36,8 +36,8 @@ describe('<PasswordRegisterWithUsername />', () => {
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
|
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||||
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
|
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
|
||||||
expect(queryByText('action.save_password')).not.toBeNull();
|
expect(queryByText('action.save_password')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ describe('<PasswordRegisterWithUsername />', () => {
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelector('input[name="new-password"]')).toBeNull();
|
expect(container.querySelector('input[name="newPassword"]')).toBeNull();
|
||||||
expect(queryByText('description.not_found')).not.toBeNull();
|
expect(queryByText('description.not_found')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -68,8 +68,8 @@ describe('<PasswordRegisterWithUsername />', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitButton = getByText('action.save_password');
|
const submitButton = getByText('action.save_password');
|
||||||
const passwordInput = container.querySelector('input[name="new-password"]');
|
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||||
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
|
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
if (passwordInput) {
|
if (passwordInput) {
|
||||||
|
|
|
@ -27,16 +27,16 @@ describe('ForgotPassword', () => {
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
|
expect(container.querySelector('input[name="newPassword"]')).not.toBeNull();
|
||||||
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
|
expect(container.querySelector('input[name="confirmPassword"]')).not.toBeNull();
|
||||||
expect(queryByText('action.save_password')).not.toBeNull();
|
expect(queryByText('action.save_password')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should submit properly', async () => {
|
test('should submit properly', async () => {
|
||||||
const { getByText, container } = renderWithPageContext(<ResetPassword />);
|
const { getByText, container } = renderWithPageContext(<ResetPassword />);
|
||||||
const submitButton = getByText('action.save_password');
|
const submitButton = getByText('action.save_password');
|
||||||
const passwordInput = container.querySelector('input[name="new-password"]');
|
const passwordInput = container.querySelector('input[name="newPassword"]');
|
||||||
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
|
const confirmPasswordInput = container.querySelector('input[name="confirmPassword"]');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
if (passwordInput) {
|
if (passwordInput) {
|
||||||
|
|
13
packages/ui/src/utils/form.ts
Normal file
13
packages/ui/src/utils/form.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import type { FieldError } from 'react-hook-form';
|
||||||
|
|
||||||
|
import type { ErrorType } from '@/components/ErrorMessage';
|
||||||
|
|
||||||
|
export const passwordErrorWatcher = (error?: FieldError): ErrorType | undefined => {
|
||||||
|
switch (error?.type) {
|
||||||
|
case 'required':
|
||||||
|
return 'password_required';
|
||||||
|
case 'minLength':
|
||||||
|
return { code: 'password_min_length', data: { min: 6 } };
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
};
|
|
@ -813,6 +813,7 @@ importers:
|
||||||
react: ^18.0.0
|
react: ^18.0.0
|
||||||
react-device-detect: ^2.2.2
|
react-device-detect: ^2.2.2
|
||||||
react-dom: ^18.0.0
|
react-dom: ^18.0.0
|
||||||
|
react-hook-form: ^7.34.0
|
||||||
react-i18next: ^11.18.3
|
react-i18next: ^11.18.3
|
||||||
react-modal: ^3.15.1
|
react-modal: ^3.15.1
|
||||||
react-router-dom: ^6.2.2
|
react-router-dom: ^6.2.2
|
||||||
|
@ -868,6 +869,7 @@ importers:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
react-device-detect: 2.2.2_biqbaboplfbrettd7655fr4n2y
|
react-device-detect: 2.2.2_biqbaboplfbrettd7655fr4n2y
|
||||||
react-dom: 18.2.0_react@18.2.0
|
react-dom: 18.2.0_react@18.2.0
|
||||||
|
react-hook-form: 7.34.0_react@18.2.0
|
||||||
react-i18next: 11.18.3_shxxmfhtk2bc4pbx5cyq3uoph4
|
react-i18next: 11.18.3_shxxmfhtk2bc4pbx5cyq3uoph4
|
||||||
react-modal: 3.15.1_biqbaboplfbrettd7655fr4n2y
|
react-modal: 3.15.1_biqbaboplfbrettd7655fr4n2y
|
||||||
react-router-dom: 6.2.2_biqbaboplfbrettd7655fr4n2y
|
react-router-dom: 6.2.2_biqbaboplfbrettd7655fr4n2y
|
||||||
|
|
Loading…
Reference in a new issue