feat: create change password frontend form

This commit is contained in:
Nicolas Meienberger 2023-04-07 23:14:36 +02:00
parent 6477164493
commit 96427705e6
7 changed files with 167 additions and 14 deletions

View file

@ -35,7 +35,7 @@ describe('ResetPasswordContainer', () => {
const newPassword = 'new_password';
const response = { email };
server.use(getTRPCMock({ path: ['auth', 'resetPassword'], type: 'mutation', response, delay: 100 }));
server.use(getTRPCMock({ path: ['auth', 'changeOperatorPassword'], type: 'mutation', response, delay: 100 }));
const passwordInput = screen.getByLabelText('Password');
const confirmPasswordInput = screen.getByLabelText('Confirm password');
@ -62,7 +62,7 @@ describe('ResetPasswordContainer', () => {
const newPassword = 'new_password';
const error = { message: 'Something went wrong' };
server.use(getTRPCMockError({ path: ['auth', 'resetPassword'], type: 'mutation', message: error.message }));
server.use(getTRPCMockError({ path: ['auth', 'changeOperatorPassword'], type: 'mutation', message: error.message }));
const passwordInput = screen.getByLabelText('Password');
const confirmPasswordInput = screen.getByLabelText('Confirm password');
@ -102,7 +102,7 @@ describe('ResetPasswordContainer', () => {
it('should redirect to login page when Back to login button is clicked', async () => {
// Arrange
render(<ResetPasswordContainer isRequested />);
server.use(getTRPCMock({ path: ['auth', 'resetPassword'], type: 'mutation', response: { email: 'goofy@test.com' } }));
server.use(getTRPCMock({ path: ['auth', 'changeOperatorPassword'], type: 'mutation', response: { email: 'goofy@test.com' } }));
const resetPasswordForm = screen.getByRole('button', { name: 'Reset password' });
const passwordInput = screen.getByLabelText('Password');
const confirmPasswordInput = screen.getByLabelText('Confirm password');

View file

@ -16,7 +16,7 @@ export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
const { addToast } = useToastStore();
const router = useRouter();
const utils = trpc.useContext();
const resetPassword = trpc.auth.resetPassword.useMutation({
const resetPassword = trpc.auth.changeOperatorPassword.useMutation({
onSuccess: () => {
utils.auth.checkPasswordChangeRequest.invalidate();
},

View file

@ -0,0 +1,81 @@
import React from 'react';
import { server } from '@/client/mocks/server';
import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
import { useToastStore } from '@/client/state/toastStore';
import { renderHook } from '@testing-library/react';
import { faker } from '@faker-js/faker';
import { render, screen, waitFor, fireEvent } from '../../../../../../tests/test-utils';
import { ChangePasswordForm } from './ChangePasswordForm';
describe('<ChangePasswordForm />', () => {
it('should show success toast upon password change', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMock({ path: ['auth', 'changePassword'], type: 'mutation', response: true }));
render(<ChangePasswordForm />);
const currentPasswordInput = screen.getByRole('textbox', { name: 'currentPassword' });
const newPasswordInput = screen.getByRole('textbox', { name: 'newPassword' });
const confirmPasswordInput = screen.getByRole('textbox', { name: 'newPasswordConfirm' });
const newPassword = faker.random.alphaNumeric(8);
// act
fireEvent.change(currentPasswordInput, { target: { value: 'test' } });
fireEvent.change(newPasswordInput, { target: { value: newPassword } });
fireEvent.change(confirmPasswordInput, { target: { value: newPassword } });
const submitButton = screen.getByRole('button', { name: /Change password/i });
submitButton.click();
// assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0]?.status).toEqual('success');
expect(result.current.toasts[0]?.description).toEqual('Password successfully changed');
});
});
it('should show error toast if change password failed', async () => {
// arrange
const { result } = renderHook(() => useToastStore());
server.use(getTRPCMockError({ path: ['auth', 'changePassword'], type: 'mutation', message: 'Invalid password' }));
render(<ChangePasswordForm />);
const currentPasswordInput = screen.getByRole('textbox', { name: 'currentPassword' });
const newPasswordInput = screen.getByRole('textbox', { name: 'newPassword' });
const confirmPasswordInput = screen.getByRole('textbox', { name: 'newPasswordConfirm' });
const newPassword = faker.random.alphaNumeric(8);
// act
fireEvent.change(currentPasswordInput, { target: { value: faker.random.alphaNumeric(8) } });
fireEvent.change(newPasswordInput, { target: { value: newPassword } });
fireEvent.change(confirmPasswordInput, { target: { value: newPassword } });
const submitButton = screen.getByRole('button', { name: /Change password/i });
submitButton.click();
// assert
await waitFor(() => {
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0]?.status).toEqual('error');
expect(result.current.toasts[0]?.title).toEqual('Error');
expect(result.current.toasts[0]?.description).toEqual('Invalid password');
});
});
it('should show error in the form if passwords do not match', async () => {
// arrange
render(<ChangePasswordForm />);
const currentPasswordInput = screen.getByRole('textbox', { name: 'currentPassword' });
const newPasswordInput = screen.getByRole('textbox', { name: 'newPassword' });
const confirmPasswordInput = screen.getByRole('textbox', { name: 'newPasswordConfirm' });
// act
fireEvent.change(currentPasswordInput, { target: { value: 'test' } });
fireEvent.change(newPasswordInput, { target: { value: faker.random.alphaNumeric(8) } });
fireEvent.change(confirmPasswordInput, { target: { value: faker.random.alphaNumeric(8) } });
const submitButton = screen.getByRole('button', { name: /Change password/i });
submitButton.click();
// assert
await waitFor(() => {
expect(screen.getByText(/Passwords do not match/i)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,64 @@
import React from 'react';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { trpc } from '@/utils/trpc';
import { useToastStore } from '@/client/state/toastStore';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router';
const schema = z
.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
newPasswordConfirm: z.string().min(8, 'Password must be at least 8 characters'),
})
.superRefine((data, ctx) => {
if (data.newPassword !== data.newPasswordConfirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match',
path: ['newPasswordConfirm'],
});
}
});
type FormValues = z.infer<typeof schema>;
export const ChangePasswordForm = () => {
const router = useRouter();
const { addToast } = useToastStore();
const changePassword = trpc.auth.changePassword.useMutation({
onError: (e) => {
addToast({ title: 'Error', description: e.message, status: 'error' });
},
onSuccess: () => {
addToast({ title: 'Success', description: 'Password successfully changed', status: 'success' });
router.push('/');
},
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = (values: FormValues) => {
changePassword.mutate(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="mb-4 w-100 ">
<Input disabled={changePassword.isLoading} {...register('currentPassword')} error={errors.currentPassword?.message} type="password" placeholder="Current password" />
<Input disabled={changePassword.isLoading} {...register('newPassword')} error={errors.newPassword?.message} className="mt-2" type="password" placeholder="New password" />
<Input disabled={changePassword.isLoading} {...register('newPasswordConfirm')} error={errors.newPasswordConfirm?.message} className="mt-2" type="password" placeholder="Confirm new password" />
<Button disabled={changePassword.isLoading} className="mt-3" type="submit">
Change password
</Button>
</form>
);
};

View file

@ -0,0 +1 @@
export { ChangePasswordForm } from './ChangePasswordForm';

View file

@ -1,7 +1,6 @@
import React from 'react';
import { trpc } from '@/utils/trpc';
import { Switch } from '@/components/ui/Switch';
import { IconLock } from '@tabler/icons-react';
import { useToastStore } from '@/client/state/toastStore';
import { Button } from '@/components/ui/Button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
@ -99,15 +98,6 @@ export const OtpForm = () => {
return (
<>
<div className="d-flex">
<IconLock className="me-2" />
<h2>Two-Factor Authentication</h2>
</div>
<p className="text-muted">
Two-factor authentication (2FA) adds an additional layer of security to your account.
<br />
When enabled, you will be prompted to enter a code from your authenticator app when you log in.
</p>
{!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totp_enabled} label="Enable two-factor authentication" />}
{getTotpUri.isLoading && (
<div className="progress w-50">

View file

@ -1,9 +1,26 @@
import React from 'react';
import { IconLock, IconKey } from '@tabler/icons-react';
import { OtpForm } from '../../components/OtpForm';
import { ChangePasswordForm } from '../../components/ChangePasswordForm';
export const SecurityContainer = () => {
return (
<div className="card-body">
<div className="d-flex">
<IconKey className="me-2" />
<h2>Change password</h2>
</div>
<p className="text-muted">Changing your password will log you out of all devices.</p>
<ChangePasswordForm />
<div className="d-flex">
<IconLock className="me-2" />
<h2>Two-Factor Authentication</h2>
</div>
<p className="text-muted">
Two-factor authentication (2FA) adds an additional layer of security to your account.
<br />
When enabled, you will be prompted to enter a code from your authenticator app when you log in.
</p>
<OtpForm />
</div>
);