feat: move reset password flow to RSC

This commit is contained in:
Nicolas Meienberger 2023-09-18 14:52:55 -07:00 committed by Nicolas Meienberger
parent 93dc514a2b
commit 944b886148
18 changed files with 127 additions and 347 deletions

View file

@ -0,0 +1,52 @@
'use client';
import React from 'react';
import { useAction } from 'next-safe-action/hook';
import { toast } from 'react-hot-toast';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/Button';
import { resetPasswordAction } from '@/actions/reset-password/reset-password-action';
import { cancelResetPasswordAction } from '@/actions/cancel-reset-password/cancel-reset-password-action';
import { ResetPasswordForm } from '../ResetPasswordForm';
export const ResetPasswordContainer: React.FC = () => {
const t = useTranslations();
const router = useRouter();
const resetPasswordMutation = useAction(resetPasswordAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
}
},
});
const cancelRequestMutation = useAction(cancelResetPasswordAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
}
},
});
if (resetPasswordMutation.res.data?.success && resetPasswordMutation.res.data?.email) {
return (
<>
<h2 className="h2 text-center mb-3">{t('auth.reset-password.success-title')}</h2>
<p>{t('auth.reset-password.success', { email: resetPasswordMutation.res.data.email })}</p>
<Button onClick={() => router.push('/login')} type="button" className="btn btn-primary w-100">
{t('auth.reset-password.back-to-login')}
</Button>
</>
);
}
return (
<ResetPasswordForm
loading={resetPasswordMutation.isExecuting}
onCancel={() => cancelRequestMutation.execute()}
onSubmit={({ password }) => resetPasswordMutation.execute({ newPassword: password })}
/>
);
};

View file

@ -3,8 +3,8 @@ import React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useTranslations } from 'next-intl';
import { Button } from '../../../../components/ui/Button';
import { Input } from '../../../../components/ui/Input';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
interface IProps {
onSubmit: (values: FormValues) => void;

View file

@ -0,0 +1,23 @@
import React from 'react';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { getTranslatorFromCookie } from '@/lib/get-translator';
import { ResetPasswordContainer } from './components/ResetPasswordContainer';
export default async function ResetPasswordPage() {
const isRequested = AuthServiceClass.checkPasswordChangeRequest();
const translator = await getTranslatorFromCookie();
if (isRequested) {
return <ResetPasswordContainer />;
}
return (
<>
<h2 className="h2 text-center mb-3">{translator('auth.reset-password.title')}</h2>
<p>{translator('auth.reset-password.instructions')}</p>
<pre>
<code>./runtipi-cli reset-password</code>
</pre>
</>
);
}

View file

@ -0,0 +1,24 @@
'use server';
import { z } from 'zod';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { handleActionError } from '../utils/handle-action-error';
const input = z.void();
/**
* Given that a password change request has been made, cancels the password change request.
*/
export const cancelResetPasswordAction = action(input, async () => {
try {
await AuthServiceClass.cancelPasswordChangeRequest();
revalidatePath('/reset-password');
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View file

@ -0,0 +1,26 @@
'use server';
import { z } from 'zod';
import { db } from '@/server/db';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { action } from '@/lib/safe-action';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({
newPassword: z.string(),
});
/**
* Given that a password change request has been made, changes the password of the first operator.
*/
export const resetPasswordAction = action(input, async ({ newPassword }) => {
try {
const authService = new AuthServiceClass(db);
const { email } = await authService.changeOperatorPassword({ newPassword });
return { success: true, email };
} catch (e) {
return handleActionError(e);
}
});

View file

@ -31,11 +31,6 @@ export const handlers = [
response: undefined,
}),
// Auth
getTRPCMock({
path: ['auth', 'register'],
type: 'mutation',
response: true,
}),
getTRPCMock({
path: ['auth', 'me'],
type: 'query',
@ -46,11 +41,6 @@ export const handlers = [
locale: 'en',
},
}),
getTRPCMock({
path: ['auth', 'isConfigured'],
type: 'query',
response: true,
}),
// App
getTRPCMock({
path: ['app', 'getApp'],

View file

@ -1,33 +0,0 @@
import Image from 'next/image';
import React from 'react';
import { LanguageSelector } from '@/components/LanguageSelector';
import { getUrl } from '../../../../core/helpers/url-helpers';
interface IProps {
children: React.ReactNode;
}
export const AuthFormLayout: React.FC<IProps> = ({ children }) => (
<div className="page page-center">
<div className="position-absolute top-0 mt-3 end-0 me-1 pb-4">
<LanguageSelector />
</div>
<div className="container container-tight py-4">
<div className="text-center mb-4">
<Image
alt="Tipi logo"
src={getUrl('tipi.png')}
height={50}
width={50}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
</div>
<div className="card card-md">
<div className="card-body">{children}</div>
</div>
</div>
</div>
);

View file

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

View file

@ -1,63 +0,0 @@
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 screen.findByText('Password must be at least 8 characters');
});
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 screen.findByText('Passwords do not match');
});
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

@ -1,121 +0,0 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
import { server } from '../../../../mocks/server';
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('./runtipi-cli reset-password')).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', 'changeOperatorPassword'], 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
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', 'changeOperatorPassword'], 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(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
});
it('should call the cancel request mutation when cancel button is clicked', async () => {
// Arrange
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(screen.getByText('Password change request cancelled')).toBeInTheDocument();
});
});
it('should redirect to login page when Back to login button is clicked', async () => {
// Arrange
render(<ResetPasswordContainer isRequested />);
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');
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

@ -1,67 +0,0 @@
import { useRouter } from 'next/router';
import React from 'react';
import { toast } from 'react-hot-toast';
import { useTranslations } from 'next-intl';
import type { MessageKey } from '@/server/utils/errors';
import { Button } from '../../../../components/ui/Button';
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 t = useTranslations();
const router = useRouter();
const utils = trpc.useContext();
const resetPassword = trpc.auth.changeOperatorPassword.useMutation({
onSuccess: () => {
utils.auth.checkPasswordChangeRequest.invalidate();
},
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
});
const cancelRequest = trpc.auth.cancelPasswordChangeRequest.useMutation({
onSuccess: () => {
utils.auth.checkPasswordChangeRequest.invalidate();
toast.success('Password change request cancelled');
},
});
const handlerSubmit = (value: FormValues) => {
resetPassword.mutate({ newPassword: value.password });
};
const renderContent = () => {
if (resetPassword.isSuccess) {
return (
<>
<h2 className="h2 text-center mb-3">{t('auth.reset-password.success-title')}</h2>
<p>{t('auth.reset-password.success', { email: resetPassword.data.email })}</p>
<Button onClick={() => router.push('/login')} type="button" className="btn btn-primary w-100">
{t('auth.reset-password.back-to-login')}
</Button>
</>
);
}
if (isRequested) {
return <ResetPasswordForm onSubmit={handlerSubmit} onCancel={() => cancelRequest.mutate()} loading={resetPassword.isLoading} />;
}
return (
<>
<h2 className="h2 text-center mb-3">{t('auth.reset-password.title')}</h2>
<p>{t('auth.reset-password.instructions')}</p>
<pre>
<code>./runtipi-cli reset-password</code>
</pre>
</>
);
};
return <AuthFormLayout>{renderContent()}</AuthFormLayout>;
};

View file

@ -1,15 +0,0 @@
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

@ -1,16 +0,0 @@
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

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

View file

@ -1,13 +0,0 @@
import { getMessagesPageProps } from '@/utils/page-helpers';
import merge from 'lodash.merge';
import { GetServerSideProps } from 'next';
export { ResetPasswordPage as default } from '../client/modules/Auth/pages/ResetPasswordPage';
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const messagesProps = await getMessagesPageProps(ctx);
return merge(messagesProps, {
props: {},
});
};

View file

@ -6,14 +6,9 @@ import { db } from '../../db';
const AuthService = new AuthServiceClass(db);
export const authRouter = router({
register: publicProcedure.input(z.object({ username: z.string(), password: z.string(), locale: z.string() })).mutation(async ({ input }) => AuthService.register({ ...input })),
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)),
isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })),
// Password
checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
changeOperatorPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changeOperatorPassword({ newPassword: input.newPassword })),
cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
changePassword: protectedProcedure
.input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
.mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.userId), ...input })),