feat(auth): add reset password page, container & form
This commit is contained in:
parent
8f8b8487e6
commit
5df9adf3f3
18 changed files with 498 additions and 0 deletions
50
src/client/components/Layout/Layout.test.tsx
Normal file
50
src/client/components/Layout/Layout.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { ResetPasswordForm } from './ResetPasswordForm';
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>;
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { ResetPasswordContainer } from './ResetPasswordContainer';
|
37
src/client/modules/Auth/pages/LoginPage/LoginPage.test.tsx
Normal file
37
src/client/modules/Auth/pages/LoginPage/LoginPage.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
20
src/client/modules/Auth/pages/LoginPage/LoginPage.tsx
Normal file
20
src/client/modules/Auth/pages/LoginPage/LoginPage.tsx
Normal 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 />;
|
||||
};
|
1
src/client/modules/Auth/pages/LoginPage/index.ts
Normal file
1
src/client/modules/Auth/pages/LoginPage/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { LoginPage } from './LoginPage';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
import React from 'react';
|
||||
import { RegisterContainer } from '../../containers/RegisterContainer';
|
||||
|
||||
export const RegisterPage = () => {
|
||||
return <RegisterContainer />;
|
||||
};
|
1
src/client/modules/Auth/pages/RegisterPage/index.ts
Normal file
1
src/client/modules/Auth/pages/RegisterPage/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { RegisterPage } from './RegisterPage';
|
16
src/client/modules/Auth/pages/ResetPasswordPage.tsx
Normal file
16
src/client/modules/Auth/pages/ResetPasswordPage.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
src/client/modules/Auth/pages/ResetPasswordPage/index.ts
Normal file
1
src/client/modules/Auth/pages/ResetPasswordPage/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ResetPasswordPage } from './ResetPasswordPage';
|
1
src/pages/reset-password.tsx
Normal file
1
src/pages/reset-password.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { ResetPasswordPage as default } from '../client/modules/Auth/pages/ResetPasswordPage';
|
Loading…
Add table
Reference in a new issue