Browse Source

feat(auth): add reset password page, container & form

Nicolas Meienberger 2 years ago
parent
commit
5df9adf3f3

+ 50 - 0
src/client/components/Layout/Layout.test.tsx

@@ -0,0 +1,50 @@
+import React from 'react';
+import { render, screen, waitFor } from '../../../../tests/test-utils';
+import { getTRPCMock, getTRPCMockError } from '../../mocks/getTrpcMock';
+import { server } from '../../mocks/server';
+import { Layout } from './Layout';
+
+const pushFn = jest.fn();
+jest.mock('next/router', () => {
+  const actualRouter = jest.requireActual('next-router-mock');
+
+  return {
+    ...actualRouter,
+    useRouter: () => ({
+      ...actualRouter.useRouter(),
+      push: pushFn,
+    }),
+  };
+});
+
+describe('Test: Layout', () => {
+  it('should render correctly its children', () => {
+    render(<Layout>test</Layout>);
+
+    expect(screen.getByText('test')).toBeInTheDocument();
+  });
+
+  it('should correctly set token in localStorage when refreshToken is called', async () => {
+    // Arranger
+    server.use(getTRPCMock({ path: ['auth', 'refreshToken'], type: 'mutation', response: { token: 'fake-token' } }));
+    render(<Layout>test</Layout>);
+
+    // Act
+    await waitFor(() => {
+      expect(localStorage.getItem('token')).toBe('fake-token');
+    });
+  });
+
+  it('should remove token from local storage and redirect to login page on error', async () => {
+    // Arranger
+    server.use(getTRPCMockError({ path: ['auth', 'refreshToken'], type: 'mutation', message: 'fake-error' }));
+    render(<Layout>test</Layout>);
+    const removeItemSpy = jest.spyOn(localStorage, 'removeItem');
+
+    // Act
+    await waitFor(() => {
+      expect(removeItemSpy).toBeCalledWith('token');
+      expect(pushFn).toBeCalledWith('/login');
+    });
+  });
+});

+ 63 - 0
src/client/modules/Auth/components/ResetPasswordForm/ResetPasswordForm.test.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
+import { ResetPasswordForm } from './ResetPasswordForm';
+
+describe('ResetPasswordForm', () => {
+  it('should render the component', () => {
+    render(<ResetPasswordForm onSubmit={jest.fn()} onCancel={jest.fn()} loading={false} />);
+    expect(screen.getByText('Reset password')).toBeInTheDocument();
+    expect(screen.getByText('Cancel password change request')).toBeInTheDocument();
+  });
+
+  it('should display an error if the password is too short', async () => {
+    // Arrange
+    render(<ResetPasswordForm onSubmit={jest.fn()} onCancel={jest.fn()} loading={false} />);
+
+    // Act
+    fireEvent.input(screen.getByLabelText('Password'), { target: { value: '123' } });
+    fireEvent.input(screen.getByLabelText('Confirm password'), { target: { value: '12345678' } });
+    fireEvent.click(screen.getByText('Reset password'));
+
+    // Assert
+    await waitFor(() => expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument());
+  });
+
+  it('should display an error if the passwords do not match', async () => {
+    // Arrange
+    render(<ResetPasswordForm onSubmit={jest.fn()} onCancel={jest.fn()} loading={false} />);
+
+    // Act
+    fireEvent.input(screen.getByLabelText('Password'), { target: { value: '12345678' } });
+    fireEvent.input(screen.getByLabelText('Confirm password'), { target: { value: '123456789' } });
+    fireEvent.click(screen.getByText('Reset password'));
+
+    // Assert
+    await waitFor(() => expect(screen.getByText('Passwords do not match')).toBeInTheDocument());
+  });
+
+  it('should call the onSubmit function when the form is submitted', async () => {
+    // Arrange
+    const onSubmit = jest.fn();
+    render(<ResetPasswordForm onSubmit={onSubmit} onCancel={jest.fn()} loading={false} />);
+
+    // Act
+    fireEvent.input(screen.getByLabelText('Password'), { target: { value: '12345678' } });
+    fireEvent.input(screen.getByLabelText('Confirm password'), { target: { value: '12345678' } });
+    fireEvent.click(screen.getByText('Reset password'));
+
+    // Assert
+    await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+  });
+
+  it('should call the onCancel function when the cancel button is clicked', async () => {
+    // Arrange
+    const onCancel = jest.fn();
+    render(<ResetPasswordForm onSubmit={jest.fn()} onCancel={onCancel} loading={false} />);
+
+    // Act
+    fireEvent.click(screen.getByText('Cancel password change request'));
+
+    // Assert
+    await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1));
+  });
+});

+ 61 - 0
src/client/modules/Auth/components/ResetPasswordForm/ResetPasswordForm.tsx

@@ -0,0 +1,61 @@
+import { zodResolver } from '@hookform/resolvers/zod';
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { Button } from '../../../../components/ui/Button';
+import { Input } from '../../../../components/ui/Input';
+
+interface IProps {
+  onSubmit: (values: FormValues) => void;
+  onCancel: () => void;
+  loading: boolean;
+}
+
+type FormValues = { password: string; passwordConfirm: string };
+
+const schema = z
+  .object({
+    password: z.string().min(8, 'Password must be at least 8 characters'),
+    passwordConfirm: z.string().min(8, 'Password must be at least 8 characters'),
+  })
+  .superRefine((data, ctx) => {
+    if (data.password !== data.passwordConfirm) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: 'Passwords do not match',
+        path: ['passwordConfirm'],
+      });
+    }
+  });
+
+export const ResetPasswordForm: React.FC<IProps> = ({ onSubmit, loading, onCancel }) => {
+  const {
+    register,
+    handleSubmit,
+    formState: { errors },
+  } = useForm<FormValues>({
+    resolver: zodResolver(schema),
+  });
+
+  return (
+    <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
+      <h2 className="h2 text-center mb-3">Reset your password</h2>
+      <Input {...register('password')} label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your new password" />
+      <Input
+        {...register('passwordConfirm')}
+        label="Confirm password"
+        error={errors.passwordConfirm?.message}
+        disabled={loading}
+        type="password"
+        className="mb-3"
+        placeholder="Confirm your new password"
+      />
+      <Button loading={loading} type="submit" className="btn btn-primary w-100">
+        Reset password
+      </Button>
+      <Button onClick={onCancel} type="button" className="btn btn-secondary w-100 mt-3">
+        Cancel password change request
+      </Button>
+    </form>
+  );
+};

+ 1 - 0
src/client/modules/Auth/components/ResetPasswordForm/index.ts

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

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

@@ -0,0 +1,128 @@
+import React from 'react';
+import { fireEvent, render, screen, waitFor, renderHook } from '../../../../../../tests/test-utils';
+import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
+import { server } from '../../../../mocks/server';
+import { useToastStore } from '../../../../state/toastStore';
+import { ResetPasswordContainer } from './ResetPasswordContainer';
+
+const pushFn = jest.fn();
+jest.mock('next/router', () => {
+  const actualRouter = jest.requireActual('next-router-mock');
+
+  return {
+    ...actualRouter,
+    useRouter: () => ({
+      ...actualRouter.useRouter(),
+      push: pushFn,
+    }),
+  };
+});
+
+describe('ResetPasswordContainer', () => {
+  it('should render the component', () => {
+    render(<ResetPasswordContainer isRequested={false} />);
+    expect(screen.getByText('Reset your password')).toBeInTheDocument();
+    expect(screen.getByText('Run this command on your server and then refresh this page')).toBeInTheDocument();
+    expect(screen.getByText('./scripts/reset-password.sh')).toBeInTheDocument();
+  });
+
+  it('should render the password reset success message', async () => {
+    // Arrange
+    const email = 'test@test.com';
+
+    render(<ResetPasswordContainer isRequested />);
+    const resetPasswordForm = screen.getByRole('button', { name: 'Reset password' });
+
+    const newPassword = 'new_password';
+    const response = { email };
+    server.use(getTRPCMock({ path: ['auth', 'resetPassword'], type: 'mutation', response, delay: 100 }));
+
+    const passwordInput = screen.getByLabelText('Password');
+    const confirmPasswordInput = screen.getByLabelText('Confirm password');
+
+    // Act
+    fireEvent.change(passwordInput, { target: { value: newPassword } });
+    fireEvent.change(confirmPasswordInput, { target: { value: newPassword } });
+    fireEvent.click(resetPasswordForm);
+
+    // Assert
+    await waitFor(() => {
+      expect(screen.getByText('Password reset')).toBeInTheDocument();
+      expect(screen.getByText(`Your password has been reset. You can now login with your new password. And your email ${email}`)).toBeInTheDocument();
+      expect(screen.getByText('Back to login')).toBeInTheDocument();
+    });
+  });
+
+  it('should show error toast if reset password mutation fails', async () => {
+    // Arrange
+    const { result, unmount } = renderHook(() => useToastStore());
+    render(<ResetPasswordContainer isRequested />);
+    const resetPasswordForm = screen.getByRole('button', { name: 'Reset password' });
+    fireEvent.click(resetPasswordForm);
+
+    const newPassword = 'new_password';
+    const error = { message: 'Something went wrong' };
+    server.use(getTRPCMockError({ path: ['auth', 'resetPassword'], type: 'mutation', message: error.message }));
+
+    const passwordInput = screen.getByLabelText('Password');
+    const confirmPasswordInput = screen.getByLabelText('Confirm password');
+
+    // Act
+    fireEvent.change(passwordInput, { target: { value: newPassword } });
+    fireEvent.change(confirmPasswordInput, { target: { value: newPassword } });
+    fireEvent.click(resetPasswordForm);
+
+    // Assert
+    await waitFor(() => {
+      expect(result.current.toasts[0].description).toBe(error.message);
+    });
+
+    unmount();
+  });
+
+  it('should call the cancel request mutation when cancel button is clicked', async () => {
+    // Arrange
+    const { result, unmount } = renderHook(() => useToastStore());
+    render(<ResetPasswordContainer isRequested />);
+    server.use(getTRPCMock({ path: ['auth', 'cancelPasswordChangeRequest'], type: 'mutation', response: true }));
+
+    const cancelRequestForm = screen.getByRole('button', { name: 'Cancel password change request' });
+
+    // Act
+    fireEvent.click(cancelRequestForm);
+
+    // Assert
+    await waitFor(() => {
+      expect(result.current.toasts[0].title).toBe('Password change request cancelled');
+    });
+
+    unmount();
+  });
+
+  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' } }));
+    const resetPasswordForm = screen.getByRole('button', { name: 'Reset password' });
+    const passwordInput = screen.getByLabelText('Password');
+    const confirmPasswordInput = screen.getByLabelText('Confirm password');
+
+    const newPassword = 'new_password';
+    fireEvent.change(passwordInput, { target: { value: newPassword } });
+    fireEvent.change(confirmPasswordInput, { target: { value: newPassword } });
+    fireEvent.click(resetPasswordForm);
+
+    await waitFor(() => {
+      expect(screen.getByText('Back to login')).toBeInTheDocument();
+    });
+
+    // Act
+    const backToLoginButton = screen.getByRole('button', { name: 'Back to login' });
+    fireEvent.click(backToLoginButton);
+
+    // Assert
+    await waitFor(() => {
+      expect(pushFn).toHaveBeenCalledWith('/login');
+    });
+  });
+});

+ 67 - 0
src/client/modules/Auth/containers/ResetPasswordContainer/ResetPasswordContainer.tsx

@@ -0,0 +1,67 @@
+import { useRouter } from 'next/router';
+import React from 'react';
+import { Button } from '../../../../components/ui/Button';
+import { useToastStore } from '../../../../state/toastStore';
+import { trpc } from '../../../../utils/trpc';
+import { AuthFormLayout } from '../../components/AuthFormLayout';
+import { ResetPasswordForm } from '../../components/ResetPasswordForm';
+
+type Props = {
+  isRequested: boolean;
+};
+
+type FormValues = { password: string };
+
+export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
+  const { addToast } = useToastStore();
+  const router = useRouter();
+  const utils = trpc.useContext();
+  const resetPassword = trpc.auth.resetPassword.useMutation({
+    onSuccess: () => {
+      utils.auth.checkPasswordChangeRequest.invalidate();
+    },
+    onError: (error) => {
+      addToast({ title: 'Reset password error', description: error.message, status: 'error' });
+    },
+  });
+  const cancelRequest = trpc.auth.cancelPasswordChangeRequest.useMutation({
+    onSuccess: () => {
+      utils.auth.checkPasswordChangeRequest.invalidate();
+      addToast({ title: 'Password change request cancelled', status: 'success' });
+    },
+  });
+
+  const handlerSubmit = (value: FormValues) => {
+    resetPassword.mutate({ newPassword: value.password });
+  };
+
+  const renderContent = () => {
+    if (resetPassword.isSuccess) {
+      return (
+        <>
+          <h2 className="h2 text-center mb-3">Password reset</h2>
+          <p>Your password has been reset. You can now login with your new password. And your email {resetPassword.data.email}</p>
+          <Button onClick={() => router.push('/login')} type="button" className="btn btn-primary w-100">
+            Back to login
+          </Button>
+        </>
+      );
+    }
+
+    if (isRequested) {
+      return <ResetPasswordForm onSubmit={handlerSubmit} onCancel={() => cancelRequest.mutate()} loading={resetPassword.isLoading} />;
+    }
+
+    return (
+      <>
+        <h2 className="h2 text-center mb-3">Reset your password</h2>
+        <p>Run this command on your server and then refresh this page</p>
+        <pre>
+          <code>./scripts/reset-password.sh</code>
+        </pre>
+      </>
+    );
+  };
+
+  return <AuthFormLayout>{renderContent()}</AuthFormLayout>;
+};

+ 1 - 0
src/client/modules/Auth/containers/ResetPasswordContainer/index.ts

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

+ 37 - 0
src/client/modules/Auth/pages/LoginPage/LoginPage.test.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+import { render, screen, waitFor } from '../../../../../../tests/test-utils';
+import { getTRPCMock } from '../../../../mocks/getTrpcMock';
+import { server } from '../../../../mocks/server';
+import { LoginPage } from './LoginPage';
+
+const pushFn = jest.fn();
+jest.mock('next/router', () => {
+  const actualRouter = jest.requireActual('next-router-mock');
+
+  return {
+    ...actualRouter,
+    useRouter: () => ({
+      ...actualRouter.useRouter(),
+      push: pushFn,
+    }),
+  };
+});
+describe('Test: LoginPage', () => {
+  it('should render correctly', async () => {
+    render(<LoginPage />);
+    server.use(getTRPCMock({ path: ['auth', 'isConfigured'], response: true }));
+
+    await waitFor(() => {
+      expect(screen.getByText('Login')).toBeInTheDocument();
+    });
+  });
+
+  it('should redirect to register page when isConfigured is false', async () => {
+    render(<LoginPage />);
+    server.use(getTRPCMock({ path: ['auth', 'isConfigured'], response: false }));
+
+    await waitFor(() => {
+      expect(pushFn).toBeCalledWith('/register');
+    });
+  });
+});

+ 20 - 0
src/client/modules/Auth/pages/LoginPage/LoginPage.tsx

@@ -0,0 +1,20 @@
+import { useRouter } from 'next/router';
+import React from 'react';
+import { StatusScreen } from '../../../../components/StatusScreen';
+import { trpc } from '../../../../utils/trpc';
+import { LoginContainer } from '../../containers/LoginContainer';
+
+export const LoginPage = () => {
+  const router = useRouter();
+  const { data, isLoading } = trpc.auth.isConfigured.useQuery();
+
+  if (data === false) {
+    router.push('/register');
+  }
+
+  if (isLoading) {
+    return <StatusScreen title="" subtitle="" />;
+  }
+
+  return <LoginContainer />;
+};

+ 1 - 0
src/client/modules/Auth/pages/LoginPage/index.ts

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

+ 13 - 0
src/client/modules/Auth/pages/RegisterPage/RegisterPage.test.tsx

@@ -0,0 +1,13 @@
+import React from 'react';
+import { render, waitFor, screen } from '../../../../../../tests/test-utils';
+import { RegisterPage } from './RegisterPage';
+
+describe('Test: RegisterPage', () => {
+  it('should render correctly', async () => {
+    render(<RegisterPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Register')).toBeInTheDocument();
+    });
+  });
+});

+ 6 - 0
src/client/modules/Auth/pages/RegisterPage/RegisterPage.tsx

@@ -0,0 +1,6 @@
+import React from 'react';
+import { RegisterContainer } from '../../containers/RegisterContainer';
+
+export const RegisterPage = () => {
+  return <RegisterContainer />;
+};

+ 1 - 0
src/client/modules/Auth/pages/RegisterPage/index.ts

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

+ 16 - 0
src/client/modules/Auth/pages/ResetPasswordPage.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+import { ResetPasswordContainer } from '../containers/ResetPasswordContainer/ResetPasswordContainer';
+import { trpc } from '../../../utils/trpc';
+import { ErrorPage } from '../../../components/ui/ErrorPage';
+
+export const ResetPasswordPage = () => {
+  const { data, error } = trpc.auth.checkPasswordChangeRequest.useQuery();
+
+  // TODO: Add loading state
+  return (
+    <>
+      {error && <ErrorPage error={error.message} />}
+      <ResetPasswordContainer isRequested={Boolean(data)} />
+    </>
+  );
+};

+ 15 - 0
src/client/modules/Auth/pages/ResetPasswordPage/ResetPasswordPage.test.tsx

@@ -0,0 +1,15 @@
+import React from 'react';
+import { render, screen, waitFor } from '../../../../../../tests/test-utils';
+import { getTRPCMock } from '../../../../mocks/getTrpcMock';
+import { server } from '../../../../mocks/server';
+import { ResetPasswordPage } from './ResetPasswordPage';
+
+describe('Test: ResetPasswordPage', () => {
+  it('should render correctly', async () => {
+    render(<ResetPasswordPage />);
+    server.use(getTRPCMock({ path: ['auth', 'checkPasswordChangeRequest'], response: false }));
+    await waitFor(() => {
+      expect(screen.getByText('Reset your password')).toBeInTheDocument();
+    });
+  });
+});

+ 16 - 0
src/client/modules/Auth/pages/ResetPasswordPage/ResetPasswordPage.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+import { ResetPasswordContainer } from '../../containers/ResetPasswordContainer/ResetPasswordContainer';
+import { trpc } from '../../../../utils/trpc';
+import { ErrorPage } from '../../../../components/ui/ErrorPage';
+
+export const ResetPasswordPage = () => {
+  const { data, error } = trpc.auth.checkPasswordChangeRequest.useQuery();
+
+  // TODO: Add loading state
+  return (
+    <>
+      {error && <ErrorPage error={error.message} />}
+      <ResetPasswordContainer isRequested={Boolean(data)} />
+    </>
+  );
+};

+ 1 - 0
src/client/modules/Auth/pages/ResetPasswordPage/index.ts

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

+ 1 - 0
src/pages/reset-password.tsx

@@ -0,0 +1 @@
+export { ResetPasswordPage as default } from '../client/modules/Auth/pages/ResetPasswordPage';