mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge pull request #196 from documenso/feat/password-reset
feat: reset password
This commit is contained in:
115
apps/web/components/forgot-password.tsx
Normal file
115
apps/web/components/forgot-password.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@documenso/ui";
|
||||||
|
import Logo from "./logo";
|
||||||
|
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
|
interface ForgotPasswordForm {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForgotPassword() {
|
||||||
|
const { register, formState, resetField, handleSubmit } = useForm<ForgotPasswordForm>();
|
||||||
|
const [resetSuccessful, setResetSuccessful] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async (values: ForgotPasswordForm) => {
|
||||||
|
const response = await toast.promise(
|
||||||
|
fetch(`/api/auth/forgot-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Sending...",
|
||||||
|
success: "Reset link sent.",
|
||||||
|
error: "Could not send reset link :/",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.dismiss();
|
||||||
|
|
||||||
|
if (response.status == 404) {
|
||||||
|
toast.error("Email address not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status == 400) {
|
||||||
|
toast.error("Password reset requested.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status == 500) {
|
||||||
|
toast.error("Something went wrong.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setResetSuccessful(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetField("email");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
<div>
|
||||||
|
<Logo className="mx-auto h-20 w-auto"></Logo>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
|
||||||
|
{resetSuccessful ? "Reset Password" : "Forgot Password?"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
{resetSuccessful
|
||||||
|
? "Please check your email for reset instructions."
|
||||||
|
: "No worries, we'll send you reset instructions."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!resetSuccessful && (
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="-space-y-px rounded-md shadow-sm">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email-address" className="sr-only">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register("email")}
|
||||||
|
id="email-address"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
|
||||||
|
placeholder="Email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={formState.isSubmitting}
|
||||||
|
className="group relative flex w-full">
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Link href="/login">
|
||||||
|
<div className="relative mt-10 flex items-center justify-center gap-2 text-sm text-gray-500 hover:cursor-pointer hover:text-gray-900">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
Back to log in
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -111,9 +111,11 @@ export default function Login(props: any) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<a href="#" className="hover:text-neon-700 font-medium text-gray-500">
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="hover:text-neon-700 font-medium text-gray-500">
|
||||||
Forgot your password?
|
Forgot your password?
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
143
apps/web/components/reset-password.tsx
Normal file
143
apps/web/components/reset-password.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { Button } from "@documenso/ui";
|
||||||
|
import Logo from "./logo";
|
||||||
|
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
const ZResetPasswordFormSchema = z
|
||||||
|
.object({
|
||||||
|
password: z.string().min(8, { message: "Password must be at least 8 characters" }),
|
||||||
|
confirmPassword: z.string().min(8, { message: "Password must be at least 8 characters" }),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
message: "Password don't match",
|
||||||
|
});
|
||||||
|
|
||||||
|
type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { token } = router.query;
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
} = useForm<TResetPasswordFormSchema>({
|
||||||
|
resolver: zodResolver(ZResetPasswordFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [resetSuccessful, setResetSuccessful] = useState(false);
|
||||||
|
|
||||||
|
const onSubmit = async ({ password }: TResetPasswordFormSchema) => {
|
||||||
|
const response = await toast.promise(
|
||||||
|
fetch(`/api/auth/reset-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password, token }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Resetting...",
|
||||||
|
success: `Reset password successful`,
|
||||||
|
error: "Could not reset password :/",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.dismiss();
|
||||||
|
const error = await response.json();
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setResetSuccessful(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/login");
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
<div>
|
||||||
|
<Logo className="mx-auto h-20 w-auto"></Logo>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
|
||||||
|
Reset Password
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
{resetSuccessful ? "Your password has been reset." : "Please chose your new password"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!resetSuccessful && (
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="-space-y-px rounded-md shadow-sm">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register("password", { required: "Password is required" })}
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
|
||||||
|
placeholder="New password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register("confirmPassword")}
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-b-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors && (
|
||||||
|
<span className="text-xs text-red-500">{errors.confirmPassword?.message}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="group relative flex w-full">
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Link href="/login">
|
||||||
|
<div className="relative mt-10 flex items-center justify-center gap-2 text-sm text-gray-500 hover:cursor-pointer hover:text-gray-900">
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
Back to log in
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/web/pages/api/auth/forgot-password.ts
Normal file
63
apps/web/pages/api/auth/forgot-password.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { sendResetPassword } from "@documenso/lib/mail";
|
||||||
|
import { defaultHandler, defaultResponder } from "@documenso/lib/server";
|
||||||
|
import prisma from "@documenso/prisma";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { email } = req.body;
|
||||||
|
const cleanEmail = email.toLowerCase();
|
||||||
|
|
||||||
|
if (!cleanEmail || !/.+@.+/.test(cleanEmail)) {
|
||||||
|
res.status(400).json({ message: "Invalid email" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: cleanEmail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(200).json({ message: "A password reset email has been sent." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingToken = await prisma.passwordResetToken.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
createdAt: {
|
||||||
|
gte: new Date(Date.now() - 1000 * 60 * 60),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingToken) {
|
||||||
|
return res.status(200).json({ message: "A password reset email has been sent." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(64).toString("hex");
|
||||||
|
const expiry = new Date();
|
||||||
|
expiry.setHours(expiry.getHours() + 24); // Set expiry to one hour from now
|
||||||
|
|
||||||
|
let passwordResetToken;
|
||||||
|
try {
|
||||||
|
passwordResetToken = await prisma.passwordResetToken.create({
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
expiry,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: "Something went wrong" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendResetPassword(user, passwordResetToken.token);
|
||||||
|
|
||||||
|
return res.status(200).json({ message: "A password reset email has been sent." });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultHandler({
|
||||||
|
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
|
||||||
|
});
|
||||||
69
apps/web/pages/api/auth/reset-password.ts
Normal file
69
apps/web/pages/api/auth/reset-password.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { hashPassword, verifyPassword } from "@documenso/lib/auth";
|
||||||
|
import { sendResetPasswordSuccessMail } from "@documenso/lib/mail";
|
||||||
|
import { defaultHandler, defaultResponder } from "@documenso/lib/server";
|
||||||
|
import prisma from "@documenso/prisma";
|
||||||
|
|
||||||
|
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { token, password } = req.body;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).json({ message: "Invalid token" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundToken = await prisma.passwordResetToken.findUnique({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
User: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!foundToken) {
|
||||||
|
return res.status(404).json({ message: "Invalid token." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (now > foundToken.expiry) {
|
||||||
|
return res.status(400).json({ message: "Token has expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSamePassword = await verifyPassword(password, foundToken.User.password!);
|
||||||
|
|
||||||
|
if (isSamePassword) {
|
||||||
|
return res.status(400).json({ message: "New password must be different" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
|
const transaction = await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: foundToken.userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.passwordResetToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: foundToken.userId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return res.status(500).json({ message: "Error resetting password." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendResetPasswordSuccessMail(foundToken.User);
|
||||||
|
|
||||||
|
res.status(200).json({ message: "Password reset successful." });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defaultHandler({
|
||||||
|
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
|
||||||
|
});
|
||||||
@ -8,13 +8,13 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const { email, password, source } = req.body;
|
const { email, password, source } = req.body;
|
||||||
const cleanEmail = email.toLowerCase();
|
const cleanEmail = email.toLowerCase();
|
||||||
|
|
||||||
if (!cleanEmail || !cleanEmail.includes("@")) {
|
if (!cleanEmail || !/.+@.+/.test(cleanEmail)) {
|
||||||
res.status(422).json({ message: "Invalid email" });
|
res.status(400).json({ message: "Invalid email" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password || password.trim().length < 7) {
|
if (!password || password.trim().length < 7) {
|
||||||
return res.status(422).json({
|
return res.status(400).json({
|
||||||
message: "Password should be at least 7 characters long.",
|
message: "Password should be at least 7 characters long.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
30
apps/web/pages/auth/reset/[token].tsx
Normal file
30
apps/web/pages/auth/reset/[token].tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import Head from "next/head";
|
||||||
|
import { getUserFromToken } from "@documenso/lib/server";
|
||||||
|
import ResetPassword from "../../../components/reset-password";
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Reset Password | Documenso</title>
|
||||||
|
</Head>
|
||||||
|
<ResetPassword />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps(context: any) {
|
||||||
|
const user = await getUserFromToken(context.req, context.res);
|
||||||
|
if (user)
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
source: "/login",
|
||||||
|
destination: "/dashboard",
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
20
apps/web/pages/auth/reset/index.tsx
Normal file
20
apps/web/pages/auth/reset/index.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Logo from "../../../components/logo";
|
||||||
|
|
||||||
|
export default function ResetPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
<div>
|
||||||
|
<Logo className="mx-auto h-20 w-auto"></Logo>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
|
||||||
|
Reset Password
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
The token you provided is invalid. Please try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/web/pages/forgot-password.tsx
Normal file
32
apps/web/pages/forgot-password.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { GetServerSideProps, GetServerSidePropsContext } from "next";
|
||||||
|
import Head from "next/head";
|
||||||
|
import { getUserFromToken } from "@documenso/lib/server";
|
||||||
|
import ForgotPassword from "../components/forgot-password";
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Forgot Password | Documenso</title>
|
||||||
|
</Head>
|
||||||
|
<ForgotPassword />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps({ req }: GetServerSidePropsContext) {
|
||||||
|
const user = await getUserFromToken(req);
|
||||||
|
|
||||||
|
if (user)
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
source: "/login",
|
||||||
|
destination: "/dashboard",
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
31
package-lock.json
generated
31
package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@headlessui/react": "^1.7.4",
|
"@headlessui/react": "^1.7.4",
|
||||||
"@heroicons/react": "^2.0.13",
|
"@heroicons/react": "^2.0.13",
|
||||||
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"avatar-from-initials": "^1.0.3",
|
"avatar-from-initials": "^1.0.3",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"next": "13.2.4",
|
"next": "13.2.4",
|
||||||
@ -24,7 +25,8 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.41.5",
|
"react-hook-form": "^7.41.5",
|
||||||
"react-hot-toast": "^2.4.0",
|
"react-hot-toast": "^2.4.0",
|
||||||
"react-signature-canvas": "^1.0.6"
|
"react-signature-canvas": "^1.0.6",
|
||||||
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
@ -525,6 +527,14 @@
|
|||||||
"react": ">= 16"
|
"react": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hookform/resolvers": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-z0A8K+Nxq+f83Whm/ajlwE6VtQlp/yPHZnXw7XWVPIGm1Vx0QV8KThU3BpbBRfAZ7/dYqCKKBNnQh85BkmBKkA==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-hook-form": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanwhocodes/config-array": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.11.8",
|
"version": "0.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
|
||||||
@ -8048,6 +8058,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.21.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
|
||||||
|
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/features": {
|
"packages/features": {
|
||||||
"name": "@documenso/features",
|
"name": "@documenso/features",
|
||||||
"version": "0.0.0"
|
"version": "0.0.0"
|
||||||
@ -8507,6 +8525,12 @@
|
|||||||
"integrity": "sha512-x89rFxH3SRdYaA+JCXwfe+RkE1SFTo9GcOkZettHer71Y3T7V+ogKmfw5CjTazgS3d0ClJ7p1NA+SP7VQLQcLw==",
|
"integrity": "sha512-x89rFxH3SRdYaA+JCXwfe+RkE1SFTo9GcOkZettHer71Y3T7V+ogKmfw5CjTazgS3d0ClJ7p1NA+SP7VQLQcLw==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"@hookform/resolvers": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-z0A8K+Nxq+f83Whm/ajlwE6VtQlp/yPHZnXw7XWVPIGm1Vx0QV8KThU3BpbBRfAZ7/dYqCKKBNnQh85BkmBKkA==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"@humanwhocodes/config-array": {
|
"@humanwhocodes/config-array": {
|
||||||
"version": "0.11.8",
|
"version": "0.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
|
||||||
@ -14097,6 +14121,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"version": "3.21.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
|
||||||
|
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@headlessui/react": "^1.7.4",
|
"@headlessui/react": "^1.7.4",
|
||||||
"@heroicons/react": "^2.0.13",
|
"@heroicons/react": "^2.0.13",
|
||||||
|
"@hookform/resolvers": "^3.1.0",
|
||||||
"avatar-from-initials": "^1.0.3",
|
"avatar-from-initials": "^1.0.3",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"next": "13.2.4",
|
"next": "13.2.4",
|
||||||
@ -35,7 +36,8 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.41.5",
|
"react-hook-form": "^7.41.5",
|
||||||
"react-hot-toast": "^2.4.0",
|
"react-hot-toast": "^2.4.0",
|
||||||
"react-signature-canvas": "^1.0.6"
|
"react-signature-canvas": "^1.0.6",
|
||||||
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
|
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
|
||||||
import { Document as PrismaDocument } from "@prisma/client";
|
|
||||||
|
|
||||||
export const baseEmailTemplate = (message: string, content: string) => {
|
export const baseEmailTemplate = (message: string, content: string) => {
|
||||||
const html = `
|
const html = `
|
||||||
<div style="background-color: #eaeaea; padding: 2%;">
|
<div style="background-color: #eaeaea; padding: 2%;">
|
||||||
<div style="text-align:center; margin: auto; font-size: 14px; font-color: #353434; max-width: 500px; border-radius: 0.375rem; background: white; padding: 50px">
|
<div style="text-align:center; margin: auto; font-size: 14px; color: #353434; max-width: 500px; border-radius: 0.375rem; background: white; padding: 50px">
|
||||||
<img src="${NEXT_PUBLIC_WEBAPP_URL}/logo_h.png" alt="Documenso Logo" style="width: 180px; display: block; margin: auto; margin-bottom: 14px;">
|
<img src="${NEXT_PUBLIC_WEBAPP_URL}/logo_h.png" alt="Documenso Logo" style="width: 180px; display: block; margin: auto; margin-bottom: 14px;">
|
||||||
${message}
|
${message}
|
||||||
${content}
|
${content}
|
||||||
|
|||||||
@ -2,3 +2,7 @@ export { signingRequestTemplate } from "./signingRequestTemplate";
|
|||||||
export { signingCompleteTemplate } from "./signingCompleteTemplate";
|
export { signingCompleteTemplate } from "./signingCompleteTemplate";
|
||||||
export { sendSigningRequest as sendSigningRequest } from "./sendSigningRequest";
|
export { sendSigningRequest as sendSigningRequest } from "./sendSigningRequest";
|
||||||
export { sendSigningDoneMail } from "./sendSigningDoneMail";
|
export { sendSigningDoneMail } from "./sendSigningDoneMail";
|
||||||
|
export { resetPasswordTemplate } from "./resetPasswordTemplate";
|
||||||
|
export { sendResetPassword } from "./sendResetPassword";
|
||||||
|
export { resetPasswordSuccessTemplate } from "./resetPasswordSuccessTemplate";
|
||||||
|
export { sendResetPasswordSuccessMail } from "./sendResetPasswordSuccessMail";
|
||||||
|
|||||||
51
packages/lib/mail/resetPasswordSuccessTemplate.ts
Normal file
51
packages/lib/mail/resetPasswordSuccessTemplate.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
|
export const resetPasswordSuccessTemplate = (user: User) => {
|
||||||
|
return `
|
||||||
|
<div style="background-color: #eaeaea; padding: 2%;">
|
||||||
|
<div
|
||||||
|
style="text-align:left; margin: auto; font-size: 14px; color: #353434; max-width: 500px; border-radius: 0.375rem; background: white; padding: 50px">
|
||||||
|
<img src="${NEXT_PUBLIC_WEBAPP_URL}/logo_h.png" alt="Documenso Logo"
|
||||||
|
style="width: 180px; display: block; margin-bottom: 14px;" />
|
||||||
|
|
||||||
|
<h2 style="text-align: left; margin-top: 20px; font-size: 24px; font-weight: bold">Password updated!</h2>
|
||||||
|
|
||||||
|
<p style="margin-top: 15px">
|
||||||
|
Hi ${user.name ? user.name : user.email},
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 15px">
|
||||||
|
We've changed your password as you asked. You can now sign in with your new password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 15px">
|
||||||
|
Didn't request a password change? We are here to help you secure your account, just <a href="https://documenso.com">contact us</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 15px">
|
||||||
|
<p style="font-weight: bold">
|
||||||
|
The Documenso Team
|
||||||
|
</p>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="text-align:left; margin-top: 30px">
|
||||||
|
<small>Want to send you own signing links?
|
||||||
|
<a href="https://documenso.com">Hosted Documenso is here!</a>.</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: left; line-height: 18px; color: #666666; margin: 24px">
|
||||||
|
<div style="margin-top: 12px">
|
||||||
|
<b>Need help?</b>
|
||||||
|
<br>
|
||||||
|
Contact us at <a href="mailto:hi@documenso.com">hi@documenso.com</a>
|
||||||
|
</div>
|
||||||
|
<hr size="1" style="height: 1px; border: none; color: #D8D8D8; background-color: #D8D8D8">
|
||||||
|
<div style="text-align: center">
|
||||||
|
<small>Easy and beautiful document signing by Documenso.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
export default resetPasswordSuccessTemplate;
|
||||||
46
packages/lib/mail/resetPasswordTemplate.ts
Normal file
46
packages/lib/mail/resetPasswordTemplate.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
|
||||||
|
|
||||||
|
export const resetPasswordTemplate = (ctaLink: string, ctaLabel: string) => {
|
||||||
|
const customContent = `
|
||||||
|
<h2 style="margin-top: 36px; font-size: 24px; font-weight: bold;">Forgot your password?</h2>
|
||||||
|
<p style="margin-top: 8px;">
|
||||||
|
That's okay, it happens! Click the button below to reset your password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 30px 0px; text-align: center">
|
||||||
|
<a href="${ctaLink}" style="background-color: #37f095; white-space: nowrap; color: white; border-color: transparent; border-width: 1px; border-radius: 0.375rem; font-size: 18px; padding-left: 16px; padding-right: 16px; padding-top: 10px; padding-bottom: 10px; text-decoration: none; margin-top: 4px; margin-bottom: 4px;">
|
||||||
|
${ctaLabel}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
<small>Want to send you own signing links? <a href="https://documenso.com">Hosted Documenso is here!</a>.</small>
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="background-color: #eaeaea; padding: 2%;">
|
||||||
|
<div
|
||||||
|
style="text-align:center; margin: auto; font-size: 14px; color: #353434; max-width: 500px; border-radius: 0.375rem; background: white; padding: 50px">
|
||||||
|
<img src="${NEXT_PUBLIC_WEBAPP_URL}/logo_h.png" alt="Documenso Logo"
|
||||||
|
style="width: 180px; display: block; margin: auto; margin-bottom: 14px;" />
|
||||||
|
${customContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const footer = `
|
||||||
|
<div style="text-align: left; line-height: 18px; color: #666666; margin: 24px">
|
||||||
|
<div style="margin-top: 12px">
|
||||||
|
<b>Need help?</b>
|
||||||
|
<br>
|
||||||
|
Contact us at <a href="mailto:hi@documenso.com">hi@documenso.com</a>
|
||||||
|
</div>
|
||||||
|
<hr size="1" style="height: 1px; border: none; color: #D8D8D8; background-color: #D8D8D8">
|
||||||
|
<div style="text-align: center">
|
||||||
|
<small>Easy and beautiful document signing by Documenso.</small>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return html + footer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default resetPasswordTemplate;
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import { ReadStream } from "fs";
|
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import nodemailerSendgrid from "nodemailer-sendgrid";
|
import nodemailerSendgrid from "nodemailer-sendgrid";
|
||||||
|
|
||||||
|
|||||||
14
packages/lib/mail/sendResetPassword.ts
Normal file
14
packages/lib/mail/sendResetPassword.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { resetPasswordTemplate } from "@documenso/lib/mail";
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
|
||||||
|
import { sendMail } from "./sendMail";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
|
export const sendResetPassword = async (user: User, token: string) => {
|
||||||
|
await sendMail(
|
||||||
|
user.email,
|
||||||
|
"Forgot password?",
|
||||||
|
resetPasswordTemplate(`${NEXT_PUBLIC_WEBAPP_URL}/auth/reset/${token}`, "Reset Your Password")
|
||||||
|
).catch((err) => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
};
|
||||||
11
packages/lib/mail/sendResetPasswordSuccessMail.ts
Normal file
11
packages/lib/mail/sendResetPasswordSuccessMail.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import resetPasswordSuccessTemplate from "./resetPasswordSuccessTemplate";
|
||||||
|
import { sendMail } from "./sendMail";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
|
export const sendResetPasswordSuccessMail = async (user: User) => {
|
||||||
|
await sendMail(user.email, "Password Reset Success!", resetPasswordSuccessTemplate(user)).catch(
|
||||||
|
(err) => {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
|
import { NEXT_PUBLIC_WEBAPP_URL } from "../constants";
|
||||||
import { baseEmailTemplate } from "./baseTemplate";
|
import { baseEmailTemplate } from "./baseTemplate";
|
||||||
import { Document as PrismaDocument } from "@prisma/client";
|
|
||||||
|
|
||||||
export const signingCompleteTemplate = (message: string) => {
|
export const signingCompleteTemplate = (message: string) => {
|
||||||
const customContent = `
|
const customContent = `
|
||||||
|
|||||||
@ -1,23 +1,17 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
import prisma from "@documenso/prisma";
|
import prisma from "@documenso/prisma";
|
||||||
import { User as PrismaUser } from "@prisma/client";
|
import { User as PrismaUser } from "@prisma/client";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
import { signOut } from "next-auth/react";
|
|
||||||
|
|
||||||
export async function getUserFromToken(
|
export async function getUserFromToken(
|
||||||
req: NextApiRequest,
|
req: GetServerSidePropsContext["req"] | NextRequest | NextApiRequest,
|
||||||
res: NextApiResponse
|
res?: NextApiResponse // TODO: Remove this optional parameter
|
||||||
): Promise<PrismaUser | null> {
|
): Promise<PrismaUser | null> {
|
||||||
const token = await getToken({ req });
|
const token = await getToken({ req });
|
||||||
const tokenEmail = token?.email?.toString();
|
const tokenEmail = token?.email?.toString();
|
||||||
|
|
||||||
if (!token) {
|
if (!token || !tokenEmail) {
|
||||||
if (res.status) res.status(401).send("No session token found for request.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tokenEmail) {
|
|
||||||
res.status(400).send("No email found in session token.");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,7 +20,6 @@ export async function getUserFromToken(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (res && res.status) res.status(401).end();
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PasswordResetToken" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `expiry` to the `PasswordResetToken` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PasswordResetToken" ADD COLUMN "expiry" TIMESTAMP(3) NOT NULL;
|
||||||
@ -13,17 +13,18 @@ enum IdentityProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String?
|
name String?
|
||||||
email String @unique
|
email String @unique
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
password String?
|
password String?
|
||||||
source String?
|
source String?
|
||||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
Document Document[]
|
Document Document[]
|
||||||
Subscription Subscription[]
|
Subscription Subscription[]
|
||||||
|
PasswordResetToken PasswordResetToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubscriptionStatus {
|
enum SubscriptionStatus {
|
||||||
@ -158,3 +159,12 @@ model Signature {
|
|||||||
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||||
Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
|
Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
token String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiry DateTime
|
||||||
|
userId Int
|
||||||
|
User User @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user