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

This commit is contained in:
Nicolas Meienberger 2023-02-21 22:08:01 +01:00
parent 8f8b8487e6
commit 5df9adf3f3
18 changed files with 498 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 />;
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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