diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx new file mode 100644 index 000000000..8235a80d9 --- /dev/null +++ b/apps/web/components/forgot-password.tsx @@ -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(); + 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 ( + <> +
+
+
+ +

+ {resetSuccessful ? "Reset Password" : "Forgot Password?"} +

+

+ {resetSuccessful + ? "Please check your email for reset instructions." + : "No worries, we'll send you reset instructions."} +

+
+ {!resetSuccessful && ( +
+
+
+ + +
+
+ +
+ +
+
+ )} +
+ +
+ + Back to log in +
+ +
+
+
+ + ); +} diff --git a/apps/web/components/login.tsx b/apps/web/components/login.tsx index 4f086a8e1..f78513d87 100644 --- a/apps/web/components/login.tsx +++ b/apps/web/components/login.tsx @@ -111,9 +111,11 @@ export default function Login(props: any) {
- + Forgot your password? - +
diff --git a/apps/web/components/reset-password.tsx b/apps/web/components/reset-password.tsx new file mode 100644 index 000000000..9f5f1d466 --- /dev/null +++ b/apps/web/components/reset-password.tsx @@ -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; + +export default function ResetPassword() { + const router = useRouter(); + const { token } = router.query; + + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + } = useForm({ + 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 ( + <> +
+
+
+ +

+ Reset Password +

+

+ {resetSuccessful ? "Your password has been reset." : "Please chose your new password"} +

+
+ {!resetSuccessful && ( +
+
+
+ + +
+ +
+ + +
+
+ + {errors && ( + {errors.confirmPassword?.message} + )} + +
+ +
+
+ )} + +
+ +
+ + Back to log in +
+ +
+
+
+ + ); +} diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts new file mode 100644 index 000000000..98e4a6676 --- /dev/null +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -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) }), +}); diff --git a/apps/web/pages/api/auth/reset-password.ts b/apps/web/pages/api/auth/reset-password.ts new file mode 100644 index 000000000..78a81b7d4 --- /dev/null +++ b/apps/web/pages/api/auth/reset-password.ts @@ -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) }), +}); diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index b82bf5ea2..b67f1b50f 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -8,13 +8,13 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { const { email, password, source } = req.body; const cleanEmail = email.toLowerCase(); - if (!cleanEmail || !cleanEmail.includes("@")) { - res.status(422).json({ message: "Invalid email" }); + if (!cleanEmail || !/.+@.+/.test(cleanEmail)) { + res.status(400).json({ message: "Invalid email" }); return; } 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.", }); } diff --git a/apps/web/pages/auth/reset/[token].tsx b/apps/web/pages/auth/reset/[token].tsx new file mode 100644 index 000000000..33868f762 --- /dev/null +++ b/apps/web/pages/auth/reset/[token].tsx @@ -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 ( + <> + + Reset Password | Documenso + + + + ); +} + +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: {}, + }; +} diff --git a/apps/web/pages/auth/reset/index.tsx b/apps/web/pages/auth/reset/index.tsx new file mode 100644 index 000000000..f21145422 --- /dev/null +++ b/apps/web/pages/auth/reset/index.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import Logo from "../../../components/logo"; + +export default function ResetPage() { + return ( +
+
+
+ +

+ Reset Password +

+

+ The token you provided is invalid. Please try again. +

+
+
+
+ ); +} diff --git a/apps/web/pages/forgot-password.tsx b/apps/web/pages/forgot-password.tsx new file mode 100644 index 000000000..4591921a4 --- /dev/null +++ b/apps/web/pages/forgot-password.tsx @@ -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 ( + <> + + Forgot Password | Documenso + + + + ); +} + +export async function getServerSideProps({ req }: GetServerSidePropsContext) { + const user = await getUserFromToken(req); + + if (user) + return { + redirect: { + source: "/login", + destination: "/dashboard", + permanent: false, + }, + }; + + return { + props: {}, + }; +} diff --git a/package-lock.json b/package-lock.json index 8d104b0d7..20c623d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@documenso/prisma": "*", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", + "@hookform/resolvers": "^3.1.0", "avatar-from-initials": "^1.0.3", "bcryptjs": "^2.4.3", "next": "13.2.4", @@ -24,7 +25,8 @@ "react-dom": "18.2.0", "react-hook-form": "^7.41.5", "react-hot-toast": "^2.4.0", - "react-signature-canvas": "^1.0.6" + "react-signature-canvas": "^1.0.6", + "zod": "^3.21.4" }, "devDependencies": { "@tailwindcss/forms": "^0.5.3", @@ -525,6 +527,14 @@ "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": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -8048,6 +8058,14 @@ "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": { "name": "@documenso/features", "version": "0.0.0" @@ -8507,6 +8525,12 @@ "integrity": "sha512-x89rFxH3SRdYaA+JCXwfe+RkE1SFTo9GcOkZettHer71Y3T7V+ogKmfw5CjTazgS3d0ClJ7p1NA+SP7VQLQcLw==", "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": { "version": "0.11.8", "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", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==" } } } diff --git a/package.json b/package.json index 14039e862..b3e2c802a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@documenso/prisma": "*", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", + "@hookform/resolvers": "^3.1.0", "avatar-from-initials": "^1.0.3", "bcryptjs": "^2.4.3", "next": "13.2.4", @@ -35,7 +36,8 @@ "react-dom": "18.2.0", "react-hook-form": "^7.41.5", "react-hot-toast": "^2.4.0", - "react-signature-canvas": "^1.0.6" + "react-signature-canvas": "^1.0.6", + "zod": "^3.21.4" }, "devDependencies": { "@tailwindcss/forms": "^0.5.3", diff --git a/packages/lib/mail/baseTemplate.ts b/packages/lib/mail/baseTemplate.ts index 6741a87b5..6e18c7114 100644 --- a/packages/lib/mail/baseTemplate.ts +++ b/packages/lib/mail/baseTemplate.ts @@ -1,10 +1,9 @@ import { NEXT_PUBLIC_WEBAPP_URL } from "../constants"; -import { Document as PrismaDocument } from "@prisma/client"; export const baseEmailTemplate = (message: string, content: string) => { const html = `
-
+
Documenso Logo ${message} ${content} diff --git a/packages/lib/mail/index.ts b/packages/lib/mail/index.ts index 6d49cdb6b..e4d66dc44 100644 --- a/packages/lib/mail/index.ts +++ b/packages/lib/mail/index.ts @@ -2,3 +2,7 @@ export { signingRequestTemplate } from "./signingRequestTemplate"; export { signingCompleteTemplate } from "./signingCompleteTemplate"; export { sendSigningRequest as sendSigningRequest } from "./sendSigningRequest"; export { sendSigningDoneMail } from "./sendSigningDoneMail"; +export { resetPasswordTemplate } from "./resetPasswordTemplate"; +export { sendResetPassword } from "./sendResetPassword"; +export { resetPasswordSuccessTemplate } from "./resetPasswordSuccessTemplate"; +export { sendResetPasswordSuccessMail } from "./sendResetPasswordSuccessMail"; diff --git a/packages/lib/mail/resetPasswordSuccessTemplate.ts b/packages/lib/mail/resetPasswordSuccessTemplate.ts new file mode 100644 index 000000000..25a0a9ff5 --- /dev/null +++ b/packages/lib/mail/resetPasswordSuccessTemplate.ts @@ -0,0 +1,51 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from "../constants"; +import { User } from "@prisma/client"; + +export const resetPasswordSuccessTemplate = (user: User) => { + return ` +
+
+ Documenso Logo + +

Password updated!

+ +

+ Hi ${user.name ? user.name : user.email}, +

+ +

+ We've changed your password as you asked. You can now sign in with your new password. +

+ +

+ Didn't request a password change? We are here to help you secure your account, just contact us. +

+ +

+

+ The Documenso Team +

+

+ +

+ Want to send you own signing links? + Hosted Documenso is here!. +

+
+
+
+
+ Need help? +
+ Contact us at hi@documenso.com +
+
+
+ Easy and beautiful document signing by Documenso. +
+
+`; +}; +export default resetPasswordSuccessTemplate; diff --git a/packages/lib/mail/resetPasswordTemplate.ts b/packages/lib/mail/resetPasswordTemplate.ts new file mode 100644 index 000000000..b86b404fd --- /dev/null +++ b/packages/lib/mail/resetPasswordTemplate.ts @@ -0,0 +1,46 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from "../constants"; + +export const resetPasswordTemplate = (ctaLink: string, ctaLabel: string) => { + const customContent = ` +

Forgot your password?

+

+ That's okay, it happens! Click the button below to reset your password. +

+ +

+ + ${ctaLabel} + +

+

+ Want to send you own signing links? Hosted Documenso is here!. +

`; + + const html = ` +
+
+ Documenso Logo + ${customContent} +
+
+ `; + + const footer = ` +
+
+ Need help? +
+ Contact us at hi@documenso.com +
+
+
+ Easy and beautiful document signing by Documenso. +
+
`; + + return html + footer; +}; + +export default resetPasswordTemplate; diff --git a/packages/lib/mail/sendMail.ts b/packages/lib/mail/sendMail.ts index 101981f12..fd7c6fb61 100644 --- a/packages/lib/mail/sendMail.ts +++ b/packages/lib/mail/sendMail.ts @@ -1,4 +1,3 @@ -import { ReadStream } from "fs"; import nodemailer from "nodemailer"; import nodemailerSendgrid from "nodemailer-sendgrid"; diff --git a/packages/lib/mail/sendResetPassword.ts b/packages/lib/mail/sendResetPassword.ts new file mode 100644 index 000000000..32e098a4c --- /dev/null +++ b/packages/lib/mail/sendResetPassword.ts @@ -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; + }); +}; diff --git a/packages/lib/mail/sendResetPasswordSuccessMail.ts b/packages/lib/mail/sendResetPasswordSuccessMail.ts new file mode 100644 index 000000000..6877700fb --- /dev/null +++ b/packages/lib/mail/sendResetPasswordSuccessMail.ts @@ -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; + } + ); +}; diff --git a/packages/lib/mail/signingCompleteTemplate.ts b/packages/lib/mail/signingCompleteTemplate.ts index 212e1f8ea..e32162906 100644 --- a/packages/lib/mail/signingCompleteTemplate.ts +++ b/packages/lib/mail/signingCompleteTemplate.ts @@ -1,6 +1,5 @@ import { NEXT_PUBLIC_WEBAPP_URL } from "../constants"; import { baseEmailTemplate } from "./baseTemplate"; -import { Document as PrismaDocument } from "@prisma/client"; export const signingCompleteTemplate = (message: string) => { const customContent = ` diff --git a/packages/lib/server/getUserFromToken.ts b/packages/lib/server/getUserFromToken.ts index 0cb8b72bb..f38c4abd1 100644 --- a/packages/lib/server/getUserFromToken.ts +++ b/packages/lib/server/getUserFromToken.ts @@ -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 { User as PrismaUser } from "@prisma/client"; import { getToken } from "next-auth/jwt"; -import { signOut } from "next-auth/react"; export async function getUserFromToken( - req: NextApiRequest, - res: NextApiResponse + req: GetServerSidePropsContext["req"] | NextRequest | NextApiRequest, + res?: NextApiResponse // TODO: Remove this optional parameter ): Promise { const token = await getToken({ req }); const tokenEmail = token?.email?.toString(); - if (!token) { - 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."); + if (!token || !tokenEmail) { return null; } @@ -26,7 +20,6 @@ export async function getUserFromToken( }); if (!user) { - if (res && res.status) res.status(401).end(); return null; } diff --git a/packages/prisma/migrations/20230605122017_password_reset/migration.sql b/packages/prisma/migrations/20230605122017_password_reset/migration.sql new file mode 100644 index 000000000..782a60880 --- /dev/null +++ b/packages/prisma/migrations/20230605122017_password_reset/migration.sql @@ -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; diff --git a/packages/prisma/migrations/20230605164015_expire_password_reset_token/migration.sql b/packages/prisma/migrations/20230605164015_expire_password_reset_token/migration.sql new file mode 100644 index 000000000..a3a70e575 --- /dev/null +++ b/packages/prisma/migrations/20230605164015_expire_password_reset_token/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 33fae736d..fc8463c9b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -13,17 +13,18 @@ enum IdentityProvider { } model User { - id Int @id @default(autoincrement()) - name String? - email String @unique - emailVerified DateTime? - password String? - source String? - identityProvider IdentityProvider @default(DOCUMENSO) - accounts Account[] - sessions Session[] - Document Document[] - Subscription Subscription[] + id Int @id @default(autoincrement()) + name String? + email String @unique + emailVerified DateTime? + password String? + source String? + identityProvider IdentityProvider @default(DOCUMENSO) + accounts Account[] + sessions Session[] + Document Document[] + Subscription Subscription[] + PasswordResetToken PasswordResetToken[] } enum SubscriptionStatus { @@ -158,3 +159,12 @@ model Signature { Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) 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]) +}