Browse Source

feat: create change password frontend form

Nicolas Meienberger 2 years ago
parent
commit
96427705e6

+ 3 - 3
src/client/modules/Auth/containers/ResetPasswordContainer/ResetPasswordContainer.test.tsx

@@ -35,7 +35,7 @@ describe('ResetPasswordContainer', () => {
 
 
     const newPassword = 'new_password';
     const newPassword = 'new_password';
     const response = { email };
     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 passwordInput = screen.getByLabelText('Password');
     const confirmPasswordInput = screen.getByLabelText('Confirm password');
     const confirmPasswordInput = screen.getByLabelText('Confirm password');
@@ -62,7 +62,7 @@ describe('ResetPasswordContainer', () => {
 
 
     const newPassword = 'new_password';
     const newPassword = 'new_password';
     const error = { message: 'Something went wrong' };
     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 passwordInput = screen.getByLabelText('Password');
     const confirmPasswordInput = screen.getByLabelText('Confirm 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 () => {
   it('should redirect to login page when Back to login button is clicked', async () => {
     // Arrange
     // Arrange
     render(<ResetPasswordContainer isRequested />);
     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 resetPasswordForm = screen.getByRole('button', { name: 'Reset password' });
     const passwordInput = screen.getByLabelText('Password');
     const passwordInput = screen.getByLabelText('Password');
     const confirmPasswordInput = screen.getByLabelText('Confirm password');
     const confirmPasswordInput = screen.getByLabelText('Confirm password');

+ 1 - 1
src/client/modules/Auth/containers/ResetPasswordContainer/ResetPasswordContainer.tsx

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

+ 81 - 0
src/client/modules/Settings/components/ChangePasswordForm/ChangePasswordForm.test.tsx

@@ -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();
+    });
+  });
+});

+ 64 - 0
src/client/modules/Settings/components/ChangePasswordForm/ChangePasswordForm.tsx

@@ -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>
+  );
+};

+ 1 - 0
src/client/modules/Settings/components/ChangePasswordForm/index.ts

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

+ 0 - 10
src/client/modules/Settings/components/OtpForm/OtpForm.tsx

@@ -1,7 +1,6 @@
 import React from 'react';
 import React from 'react';
 import { trpc } from '@/utils/trpc';
 import { trpc } from '@/utils/trpc';
 import { Switch } from '@/components/ui/Switch';
 import { Switch } from '@/components/ui/Switch';
-import { IconLock } from '@tabler/icons-react';
 import { useToastStore } from '@/client/state/toastStore';
 import { useToastStore } from '@/client/state/toastStore';
 import { Button } from '@/components/ui/Button';
 import { Button } from '@/components/ui/Button';
 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
@@ -99,15 +98,6 @@ export const OtpForm = () => {
 
 
   return (
   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" />}
       {!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totp_enabled} label="Enable two-factor authentication" />}
       {getTotpUri.isLoading && (
       {getTotpUri.isLoading && (
         <div className="progress w-50">
         <div className="progress w-50">

+ 17 - 0
src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.tsx

@@ -1,9 +1,26 @@
 import React from 'react';
 import React from 'react';
+import { IconLock, IconKey } from '@tabler/icons-react';
 import { OtpForm } from '../../components/OtpForm';
 import { OtpForm } from '../../components/OtpForm';
+import { ChangePasswordForm } from '../../components/ChangePasswordForm';
 
 
 export const SecurityContainer = () => {
 export const SecurityContainer = () => {
   return (
   return (
     <div className="card-body">
     <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 />
       <OtpForm />
     </div>
     </div>
   );
   );