mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
parent
ff81b0f83e
commit
f97ec56fbf
8 changed files with 331 additions and 8 deletions
|
@ -1,4 +1,5 @@
|
||||||
import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode } from './register';
|
import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode } from './register';
|
||||||
|
import { verifyResetPasswordEmailPasscode, verifyResetPasswordSmsPasscode } from './reset-password';
|
||||||
import { verifySignInEmailPasscode, verifySignInSmsPasscode } from './sign-in';
|
import { verifySignInEmailPasscode, verifySignInSmsPasscode } from './sign-in';
|
||||||
import { getVerifyPasscodeApi } from './utils';
|
import { getVerifyPasscodeApi } from './utils';
|
||||||
|
|
||||||
|
@ -8,5 +9,7 @@ describe('api', () => {
|
||||||
expect(getVerifyPasscodeApi('register', 'email')).toBe(verifyRegisterEmailPasscode);
|
expect(getVerifyPasscodeApi('register', 'email')).toBe(verifyRegisterEmailPasscode);
|
||||||
expect(getVerifyPasscodeApi('sign-in', 'sms')).toBe(verifySignInSmsPasscode);
|
expect(getVerifyPasscodeApi('sign-in', 'sms')).toBe(verifySignInSmsPasscode);
|
||||||
expect(getVerifyPasscodeApi('sign-in', 'email')).toBe(verifySignInEmailPasscode);
|
expect(getVerifyPasscodeApi('sign-in', 'email')).toBe(verifySignInEmailPasscode);
|
||||||
|
expect(getVerifyPasscodeApi('reset-password', 'email')).toBe(verifyResetPasswordEmailPasscode);
|
||||||
|
expect(getVerifyPasscodeApi('reset-password', 'sms')).toBe(verifyResetPasswordSmsPasscode);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,6 +8,12 @@ import {
|
||||||
verifyRegisterEmailPasscode,
|
verifyRegisterEmailPasscode,
|
||||||
verifyRegisterSmsPasscode,
|
verifyRegisterSmsPasscode,
|
||||||
} from './register';
|
} from './register';
|
||||||
|
import {
|
||||||
|
verifyResetPasswordEmailPasscode,
|
||||||
|
verifyResetPasswordSmsPasscode,
|
||||||
|
sendResetPasswordEmailPasscode,
|
||||||
|
sendResetPasswordSmsPasscode,
|
||||||
|
} from './reset-password';
|
||||||
import {
|
import {
|
||||||
signInBasic,
|
signInBasic,
|
||||||
sendSignInSmsPasscode,
|
sendSignInSmsPasscode,
|
||||||
|
@ -179,6 +185,44 @@ describe('api', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sendResetPasswordSmsPasscode', async () => {
|
||||||
|
await sendResetPasswordSmsPasscode(phone);
|
||||||
|
expect(ky.post).toBeCalledWith('/api/session/reset-password/sms/send-passcode', {
|
||||||
|
json: {
|
||||||
|
phone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifyResetPasswordSmsPasscode', async () => {
|
||||||
|
await verifyResetPasswordSmsPasscode(phone, code);
|
||||||
|
expect(ky.post).toBeCalledWith('/api/session/reset-password/sms/verify-passcode', {
|
||||||
|
json: {
|
||||||
|
phone,
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendResetPasswordEmailPasscode', async () => {
|
||||||
|
await sendResetPasswordEmailPasscode(email);
|
||||||
|
expect(ky.post).toBeCalledWith('/api/session/reset-password/email/send-passcode', {
|
||||||
|
json: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifyResetPasswordEmailPasscode', async () => {
|
||||||
|
await verifyResetPasswordEmailPasscode(email, code);
|
||||||
|
expect(ky.post).toBeCalledWith('/api/session/reset-password/email/verify-passcode', {
|
||||||
|
json: {
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('invokeSocialSignIn', async () => {
|
it('invokeSocialSignIn', async () => {
|
||||||
await invokeSocialSignIn('connectorId', 'state', 'redirectUri');
|
await invokeSocialSignIn('connectorId', 'state', 'redirectUri');
|
||||||
expect(ky.post).toBeCalledWith('/api/session/sign-in/social', {
|
expect(ky.post).toBeCalledWith('/api/session/sign-in/social', {
|
||||||
|
|
56
packages/ui/src/apis/reset-password.ts
Normal file
56
packages/ui/src/apis/reset-password.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import api from './api';
|
||||||
|
|
||||||
|
type Response = {
|
||||||
|
redirectTo: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendResetPasswordSmsPasscode = async (phone: string) => {
|
||||||
|
await api
|
||||||
|
.post('/api/session/reset-password/sms/send-passcode', {
|
||||||
|
json: {
|
||||||
|
phone,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyResetPasswordSmsPasscode = async (phone: string, code: string) =>
|
||||||
|
api
|
||||||
|
.post('/api/session/reset-password/sms/verify-passcode', {
|
||||||
|
json: {
|
||||||
|
phone,
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json<Response>();
|
||||||
|
|
||||||
|
export const sendResetPasswordEmailPasscode = async (email: string) => {
|
||||||
|
await api
|
||||||
|
.post('/api/session/reset-password/email/send-passcode', {
|
||||||
|
json: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyResetPasswordEmailPasscode = async (email: string, code: string) =>
|
||||||
|
api
|
||||||
|
.post('/api/session/reset-password/email/verify-passcode', {
|
||||||
|
json: {
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json<Response>();
|
||||||
|
|
||||||
|
export const resetPassword = async (password: string) =>
|
||||||
|
api
|
||||||
|
.post('/api/session/reset-password', {
|
||||||
|
json: { password },
|
||||||
|
})
|
||||||
|
.json<Response>();
|
|
@ -6,6 +6,12 @@ import {
|
||||||
sendRegisterEmailPasscode,
|
sendRegisterEmailPasscode,
|
||||||
sendRegisterSmsPasscode,
|
sendRegisterSmsPasscode,
|
||||||
} from './register';
|
} from './register';
|
||||||
|
import {
|
||||||
|
sendResetPasswordEmailPasscode,
|
||||||
|
sendResetPasswordSmsPasscode,
|
||||||
|
verifyResetPasswordEmailPasscode,
|
||||||
|
verifyResetPasswordSmsPasscode,
|
||||||
|
} from './reset-password';
|
||||||
import {
|
import {
|
||||||
verifySignInEmailPasscode,
|
verifySignInEmailPasscode,
|
||||||
verifySignInSmsPasscode,
|
verifySignInSmsPasscode,
|
||||||
|
@ -20,13 +26,11 @@ export const getSendPasscodeApi = (
|
||||||
method: PasscodeChannel
|
method: PasscodeChannel
|
||||||
): ((_address: string) => Promise<{ success: boolean }>) => {
|
): ((_address: string) => Promise<{ success: boolean }>) => {
|
||||||
if (type === 'reset-password' && method === 'email') {
|
if (type === 'reset-password' && method === 'email') {
|
||||||
// TODO: update using reset-password verification api
|
return sendResetPasswordEmailPasscode;
|
||||||
return async () => ({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'reset-password' && method === 'sms') {
|
if (type === 'reset-password' && method === 'sms') {
|
||||||
// TODO: update using reset-password verification api
|
return sendResetPasswordSmsPasscode;
|
||||||
return async () => ({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'sign-in' && method === 'email') {
|
if (type === 'sign-in' && method === 'email') {
|
||||||
|
@ -49,13 +53,11 @@ export const getVerifyPasscodeApi = (
|
||||||
method: PasscodeChannel
|
method: PasscodeChannel
|
||||||
): ((_address: string, code: string, socialToBind?: string) => Promise<{ redirectTo: string }>) => {
|
): ((_address: string, code: string, socialToBind?: string) => Promise<{ redirectTo: string }>) => {
|
||||||
if (type === 'reset-password' && method === 'email') {
|
if (type === 'reset-password' && method === 'email') {
|
||||||
// TODO: update using reset-password verification api
|
return verifyResetPasswordEmailPasscode;
|
||||||
return async () => ({ redirectTo: '' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'reset-password' && method === 'sms') {
|
if (type === 'reset-password' && method === 'sms') {
|
||||||
// TODO: update using reset-password verification api
|
return verifyResetPasswordSmsPasscode;
|
||||||
return async () => ({ redirectTo: '' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'sign-in' && method === 'email') {
|
if (type === 'sign-in' && method === 'email') {
|
||||||
|
|
13
packages/ui/src/containers/ResetPassword/index.module.scss
Normal file
13
packages/ui/src/containers/ResetPassword/index.module.scss
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
@include _.flex-column;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputField {
|
||||||
|
margin-bottom: _.unit(4);
|
||||||
|
}
|
||||||
|
}
|
112
packages/ui/src/containers/ResetPassword/index.test.tsx
Normal file
112
packages/ui/src/containers/ResetPassword/index.test.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { fireEvent, act, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||||
|
import { resetPassword } from '@/apis/reset-password';
|
||||||
|
|
||||||
|
import ResetPassword from '.';
|
||||||
|
|
||||||
|
jest.mock('@/apis/reset-password', () => ({
|
||||||
|
resetPassword: jest.fn(async () => ({ redirectTo: '/' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('<ResetPassword />', () => {
|
||||||
|
test('default render', () => {
|
||||||
|
const { queryByText, container } = renderWithPageContext(<ResetPassword />);
|
||||||
|
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
|
||||||
|
expect(queryByText('action.confirm')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password are required', () => {
|
||||||
|
const { queryByText, getByText } = renderWithPageContext(<ResetPassword />);
|
||||||
|
const submitButton = getByText('action.confirm');
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
expect(queryByText('password_required')).not.toBeNull();
|
||||||
|
expect(resetPassword).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password less than 6 chars should throw', () => {
|
||||||
|
const { queryByText, getByText, container } = renderWithPageContext(<ResetPassword />);
|
||||||
|
const submitButton = getByText('action.confirm');
|
||||||
|
const passwordInput = container.querySelector('input[name="new-password"]');
|
||||||
|
|
||||||
|
if (passwordInput) {
|
||||||
|
fireEvent.change(passwordInput, { target: { value: '12345' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryByText('password_min_length')).not.toBeNull();
|
||||||
|
|
||||||
|
expect(resetPassword).not.toBeCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
// Clear error
|
||||||
|
if (passwordInput) {
|
||||||
|
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryByText('password_min_length')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password mismatch with confirmPassword should throw', () => {
|
||||||
|
const { queryByText, getByText, container } = renderWithPageContext(<ResetPassword />);
|
||||||
|
const submitButton = getByText('action.confirm');
|
||||||
|
const passwordInput = container.querySelector('input[name="new-password"]');
|
||||||
|
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
if (passwordInput) {
|
||||||
|
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmPasswordInput) {
|
||||||
|
fireEvent.change(confirmPasswordInput, { target: { value: '012345' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryByText('passwords_do_not_match')).not.toBeNull();
|
||||||
|
|
||||||
|
expect(resetPassword).not.toBeCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
// Clear Error
|
||||||
|
if (confirmPasswordInput) {
|
||||||
|
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryByText('passwords_do_not_match')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should submit properly', async () => {
|
||||||
|
const { queryByText, getByText, container } = renderWithPageContext(<ResetPassword />);
|
||||||
|
const submitButton = getByText('action.confirm');
|
||||||
|
const passwordInput = container.querySelector('input[name="new-password"]');
|
||||||
|
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
if (passwordInput) {
|
||||||
|
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmPasswordInput) {
|
||||||
|
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryByText('passwords_do_not_match')).toBeNull();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(resetPassword).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
91
packages/ui/src/containers/ResetPassword/index.tsx
Normal file
91
packages/ui/src/containers/ResetPassword/index.tsx
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { resetPassword } from '@/apis/reset-password';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Input from '@/components/Input';
|
||||||
|
import useApi from '@/hooks/use-api';
|
||||||
|
import useForm from '@/hooks/use-form';
|
||||||
|
import { passwordValidation, confirmPasswordValidation } from '@/utils/field-validations';
|
||||||
|
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
// eslint-disable-next-line react/boolean-prop-naming
|
||||||
|
autoFocus?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldState = {
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultState: FieldState = {
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResetPassword = ({ className, autoFocus }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
|
||||||
|
|
||||||
|
const { result, run: asyncRegister } = useApi(resetPassword);
|
||||||
|
|
||||||
|
const onSubmitHandler = useCallback(
|
||||||
|
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void asyncRegister(fieldValue.password);
|
||||||
|
},
|
||||||
|
[validateForm, asyncRegister, fieldValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (result?.redirectTo) {
|
||||||
|
window.location.replace(result.redirectTo);
|
||||||
|
}
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||||
|
<Input
|
||||||
|
className={styles.inputField}
|
||||||
|
name="new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={t('input.password')}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
{...register('password', passwordValidation)}
|
||||||
|
onClear={() => {
|
||||||
|
setFieldValue((state) => ({ ...state, password: '' }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className={styles.inputField}
|
||||||
|
name="confirm-new-password"
|
||||||
|
type="password"
|
||||||
|
placeholder={t('input.confirm_password')}
|
||||||
|
{...register('confirmPassword', (confirmPassword) =>
|
||||||
|
confirmPasswordValidation(fieldValue.password, confirmPassword)
|
||||||
|
)}
|
||||||
|
errorStyling={false}
|
||||||
|
onClear={() => {
|
||||||
|
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={async () => onSubmitHandler()}>{t('action.confirm')}</Button>
|
||||||
|
|
||||||
|
<input hidden type="submit" />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPassword;
|
|
@ -1,6 +1,7 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import NavBar from '@/components/NavBar';
|
import NavBar from '@/components/NavBar';
|
||||||
|
import ResetPasswordForm from '@/containers/ResetPassword';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ const ResetPassword = () => {
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.title}>{t('description.new_password')}</div>
|
<div className={styles.title}>{t('description.new_password')}</div>
|
||||||
|
<ResetPasswordForm autoFocus />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Reference in a new issue