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 { verifyResetPasswordEmailPasscode, verifyResetPasswordSmsPasscode } from './reset-password';
|
||||
import { verifySignInEmailPasscode, verifySignInSmsPasscode } from './sign-in';
|
||||
import { getVerifyPasscodeApi } from './utils';
|
||||
|
||||
|
@ -8,5 +9,7 @@ describe('api', () => {
|
|||
expect(getVerifyPasscodeApi('register', 'email')).toBe(verifyRegisterEmailPasscode);
|
||||
expect(getVerifyPasscodeApi('sign-in', 'sms')).toBe(verifySignInSmsPasscode);
|
||||
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,
|
||||
verifyRegisterSmsPasscode,
|
||||
} from './register';
|
||||
import {
|
||||
verifyResetPasswordEmailPasscode,
|
||||
verifyResetPasswordSmsPasscode,
|
||||
sendResetPasswordEmailPasscode,
|
||||
sendResetPasswordSmsPasscode,
|
||||
} from './reset-password';
|
||||
import {
|
||||
signInBasic,
|
||||
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 () => {
|
||||
await invokeSocialSignIn('connectorId', 'state', 'redirectUri');
|
||||
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,
|
||||
sendRegisterSmsPasscode,
|
||||
} from './register';
|
||||
import {
|
||||
sendResetPasswordEmailPasscode,
|
||||
sendResetPasswordSmsPasscode,
|
||||
verifyResetPasswordEmailPasscode,
|
||||
verifyResetPasswordSmsPasscode,
|
||||
} from './reset-password';
|
||||
import {
|
||||
verifySignInEmailPasscode,
|
||||
verifySignInSmsPasscode,
|
||||
|
@ -20,13 +26,11 @@ export const getSendPasscodeApi = (
|
|||
method: PasscodeChannel
|
||||
): ((_address: string) => Promise<{ success: boolean }>) => {
|
||||
if (type === 'reset-password' && method === 'email') {
|
||||
// TODO: update using reset-password verification api
|
||||
return async () => ({ success: true });
|
||||
return sendResetPasswordEmailPasscode;
|
||||
}
|
||||
|
||||
if (type === 'reset-password' && method === 'sms') {
|
||||
// TODO: update using reset-password verification api
|
||||
return async () => ({ success: true });
|
||||
return sendResetPasswordSmsPasscode;
|
||||
}
|
||||
|
||||
if (type === 'sign-in' && method === 'email') {
|
||||
|
@ -49,13 +53,11 @@ export const getVerifyPasscodeApi = (
|
|||
method: PasscodeChannel
|
||||
): ((_address: string, code: string, socialToBind?: string) => Promise<{ redirectTo: string }>) => {
|
||||
if (type === 'reset-password' && method === 'email') {
|
||||
// TODO: update using reset-password verification api
|
||||
return async () => ({ redirectTo: '' });
|
||||
return verifyResetPasswordEmailPasscode;
|
||||
}
|
||||
|
||||
if (type === 'reset-password' && method === 'sms') {
|
||||
// TODO: update using reset-password verification api
|
||||
return async () => ({ redirectTo: '' });
|
||||
return verifyResetPasswordSmsPasscode;
|
||||
}
|
||||
|
||||
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 NavBar from '@/components/NavBar';
|
||||
import ResetPasswordForm from '@/containers/ResetPassword';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -12,6 +13,7 @@ const ResetPassword = () => {
|
|||
<NavBar />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>{t('description.new_password')}</div>
|
||||
<ResetPasswordForm autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
Loading…
Add table
Reference in a new issue