feat: move reset password flow to RSC
This commit is contained in:
parent
93dc514a2b
commit
944b886148
18 changed files with 127 additions and 347 deletions
|
@ -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 })}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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;
|
23
src/app/(auth)/reset-password/page.tsx
Normal file
23
src/app/(auth)/reset-password/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
26
src/app/actions/reset-password/reset-password-action.ts
Normal file
26
src/app/actions/reset-password/reset-password-action.ts
Normal 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);
|
||||
}
|
||||
});
|
|
@ -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'],
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -1 +0,0 @@
|
|||
export { AuthFormLayout } from './AuthFormLayout';
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>;
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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)} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export { ResetPasswordPage } from './ResetPasswordPage';
|
|
@ -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: {},
|
||||
});
|
||||
};
|
|
@ -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 })),
|
||||
|
|
Loading…
Add table
Reference in a new issue