diff --git a/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx new file mode 100644 index 000000000..15b7ebb17 --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/reset-password/[token]/page.tsx @@ -0,0 +1,36 @@ +import Image from 'next/image'; +import Link from 'next/link'; + +import backgroundPattern from '~/assets/background-pattern.png'; +import { ResetPasswordForm } from '~/components/forms/reset-password'; + +export default function ResetPasswordPage({ params }: { params: { token: string } }) { + return ( +
+
+
+ background pattern +
+ +
+

Reset Password

+ +

Please choose your new password

+ + + +

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+ ); +} diff --git a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx index d782e5039..49071b581 100644 --- a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx +++ b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx @@ -1,36 +1,5 @@ -import Image from 'next/image'; -import Link from 'next/link'; +import React from 'react'; -import backgroundPattern from '~/assets/background-pattern.png'; -import { ResetPasswordForm } from '~/components/forms/reset-password'; - -export default function ResetPasswordPage() { - return ( -
-
-
- background pattern -
- -
-

Reset Password

- -

Please choose your new password

- - - -

- Don't have an account?{' '} - - Sign up - -

-
-
-
- ); +export default function ResetPassword() { + return
ResetPassword
; } diff --git a/apps/web/src/components/forms/reset-password.tsx b/apps/web/src/components/forms/reset-password.tsx index c07b45d2b..f4e83d203 100644 --- a/apps/web/src/components/forms/reset-password.tsx +++ b/apps/web/src/components/forms/reset-password.tsx @@ -7,10 +7,12 @@ import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZResetPasswordFormSchema = z .object({ @@ -26,13 +28,17 @@ export type TResetPasswordFormSchema = z.infer; export type ResetPasswordFormProps = { className?: string; + token: string; }; -export const ResetPasswordForm = ({ className }: ResetPasswordFormProps) => { +export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) => { const router = useRouter(); + const { toast } = useToast(); + const { register, + reset, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ @@ -43,8 +49,24 @@ export const ResetPasswordForm = ({ className }: ResetPasswordFormProps) => { resolver: zodResolver(ZResetPasswordFormSchema), }); - const onFormSubmit = ({ password, repeatedPassword }: TResetPasswordFormSchema) => { - console.log(password, repeatedPassword); + const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation(); + + const onFormSubmit = async ({ password, repeatedPassword }: TResetPasswordFormSchema) => { + // TODO: Handle error with try/catch + console.log(password, repeatedPassword, token); + + await resetPassword({ + password, + token, + }); + + reset(); + + toast({ + title: 'Password updated', + description: 'Your password has been updated successfully.', + duration: 5000, + }); router.push('/signin'); }; diff --git a/packages/lib/server-only/auth/send-forgot-password.ts b/packages/lib/server-only/auth/send-forgot-password.ts index 88de43270..e0f74d4ff 100644 --- a/packages/lib/server-only/auth/send-forgot-password.ts +++ b/packages/lib/server-only/auth/send-forgot-password.ts @@ -30,7 +30,7 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions) const token = user.PasswordResetToken[0].token; const assetBaseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'; - const resetPasswordLink = `${process.env.NEXT_PUBLIC_SITE_URL}/reset/${token}`; + const resetPasswordLink = `${process.env.NEXT_PUBLIC_SITE_URL}/reset-password/${token}`; const template = createElement(ForgotPasswordTemplate, { assetBaseUrl, diff --git a/packages/lib/server-only/user/reset-password.ts b/packages/lib/server-only/user/reset-password.ts new file mode 100644 index 000000000..7ce1bcb20 --- /dev/null +++ b/packages/lib/server-only/user/reset-password.ts @@ -0,0 +1,66 @@ +import { compare, hash } from 'bcrypt'; + +import { prisma } from '@documenso/prisma'; + +import { SALT_ROUNDS } from '../../constants/auth'; + +export type ResetPasswordOptions = { + token: string; + password: string; +}; + +export const resetPassword = async ({ token, password }: ResetPasswordOptions) => { + if (!token) { + throw new Error('Invalid Token'); + } + + const foundToken = await prisma.passwordResetToken.findFirstOrThrow({ + where: { + token, + }, + include: { + User: true, + }, + }); + + if (!foundToken) { + throw new Error('Invalid Token'); + } + + const now = new Date(); + + if (now > foundToken.expiry) { + throw new Error('Token has expired'); + } + + const isSamePassword = await compare(password, foundToken.User.password!); + + if (isSamePassword) { + throw new Error('Your new password cannot be the same as your old password.'); + } + + const hashedPassword = await hash(password, SALT_ROUNDS); + + const transactions = await prisma.$transaction([ + prisma.user.update({ + where: { + id: foundToken.userId, + }, + data: { + password: hashedPassword, + }, + }), + prisma.passwordResetToken.deleteMany({ + where: { + userId: foundToken.userId, + }, + }), + ]); + + if (!transactions) { + throw new Error('Unable to update password'); + } + + // await sendResetPasswordSuccessMail(foundToken.User); + return transactions; +}; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index d5f60658f..de61f64f4 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,12 +1,14 @@ import { TRPCError } from '@trpc/server'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; +import { resetPassword } from '@documenso/lib/server-only/user/reset-password'; import { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZForgotPasswordFormSchema, + ZResetPasswordFormSchema, ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema, } from './schema'; @@ -69,6 +71,7 @@ export const profileRouter = router({ } catch (err) { console.error(err); + // TODO: Handle this error better throw new TRPCError({ code: 'BAD_REQUEST', message: @@ -76,4 +79,27 @@ export const profileRouter = router({ }); } }), + + resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input }) => { + try { + const { password, token } = input; + + return await resetPassword({ + token, + password, + }); + } catch (err) { + let message = + 'We were unable to update your profile. Please review the information you provided and try again.'; + + if (err instanceof Error) { + message = err.message; + } + + throw new TRPCError({ + code: 'BAD_REQUEST', + message, + }); + } + }), }); diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 65a296bce..641227684 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -13,6 +13,12 @@ export const ZForgotPasswordFormSchema = z.object({ email: z.string().email().min(1), }); +export const ZResetPasswordFormSchema = z.object({ + password: z.string().min(6), + token: z.string().min(1), +}); + export type TUpdateProfileMutationSchema = z.infer; export type TUpdatePasswordMutationSchema = z.infer; export type TForgotPasswordFormSchema = z.infer; +export type TResetPasswordFormSchema = z.infer;