From 19e960f5932ce8066cd0abc555c7c38bdaa9a53f Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 31 May 2023 21:11:54 +1000 Subject: [PATCH 01/42] fix: improve stripe webhook endpoint Improve the stripe webhook endpoint by checking for subscriptions prior to performing an update to handle cases where accounts have no created subscription. This can happen in sitations such as when a checkout_session has been created but the payment fails. --- packages/lib/stripe/handlers/webhook.ts | 85 ++++++++++++++++++------- 1 file changed, 61 insertions(+), 24 deletions(-) diff --git a/packages/lib/stripe/handlers/webhook.ts b/packages/lib/stripe/handlers/webhook.ts index 390fc21de..609e652fe 100644 --- a/packages/lib/stripe/handlers/webhook.ts +++ b/packages/lib/stripe/handlers/webhook.ts @@ -25,9 +25,7 @@ export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) }); } - log("constructing body...") const body = await buffer(req); - log("constructed body") const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); log("event-type:", event.type); @@ -70,23 +68,38 @@ export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) if (event.type === "invoice.payment_succeeded") { const invoice = event.data.object as Stripe.Invoice; + if (invoice.billing_reason !== "subscription_cycle") { + return res.status(200).json({ + success: true, + message: "Webhook received", + }); + } + const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string); - await prisma.subscription.update({ + const hasSubscription = await prisma.subscription.findFirst({ where: { customerId, }, - data: { - status: SubscriptionStatus.ACTIVE, - planId: subscription.id, - priceId: subscription.items.data[0].price.id, - periodEnd: new Date(subscription.current_period_end * 1000), - }, }); + if (hasSubscription) { + await prisma.subscription.update({ + where: { + customerId, + }, + data: { + status: SubscriptionStatus.ACTIVE, + planId: subscription.id, + priceId: subscription.items.data[0].price.id, + periodEnd: new Date(subscription.current_period_end * 1000), + }, + }); + } + return res.status(200).json({ success: true, message: "Webhook received", @@ -98,15 +111,23 @@ export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) const customerId = failedInvoice.customer as string; - await prisma.subscription.update({ + const hasSubscription = await prisma.subscription.findFirst({ where: { customerId, }, - data: { - status: SubscriptionStatus.PAST_DUE, - }, }); + if (hasSubscription) { + await prisma.subscription.update({ + where: { + customerId, + }, + data: { + status: SubscriptionStatus.PAST_DUE, + }, + }); + } + return res.status(200).json({ success: true, message: "Webhook received", @@ -118,18 +139,26 @@ export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) const customerId = updatedSubscription.customer as string; - await prisma.subscription.update({ + const hasSubscription = await prisma.subscription.findFirst({ where: { customerId, }, - data: { - status: SubscriptionStatus.ACTIVE, - planId: updatedSubscription.id, - priceId: updatedSubscription.items.data[0].price.id, - periodEnd: new Date(updatedSubscription.current_period_end * 1000), - }, }); + if (hasSubscription) { + await prisma.subscription.update({ + where: { + customerId, + }, + data: { + status: SubscriptionStatus.ACTIVE, + planId: updatedSubscription.id, + priceId: updatedSubscription.items.data[0].price.id, + periodEnd: new Date(updatedSubscription.current_period_end * 1000), + }, + }); + } + return res.status(200).json({ success: true, message: "Webhook received", @@ -141,15 +170,23 @@ export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) const customerId = deletedSubscription.customer as string; - await prisma.subscription.update({ + const hasSubscription = await prisma.subscription.findFirst({ where: { customerId, }, - data: { - status: SubscriptionStatus.INACTIVE, - }, }); + if (hasSubscription) { + await prisma.subscription.update({ + where: { + customerId, + }, + data: { + status: SubscriptionStatus.INACTIVE, + }, + }); + } + return res.status(200).json({ success: true, message: "Webhook received", From 316fb49339e9fd1fa64ce6cc870c44c6bb147b24 Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 2 Jun 2023 19:03:59 +1000 Subject: [PATCH 02/42] fix: disable subscriptions in example env --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index ab8d15b45..f5c2486fe 100644 --- a/.env.example +++ b/.env.example @@ -51,4 +51,4 @@ NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= #FEATURE FLAGS # Allow users to register via the /signup page. Otherwise they will be redirect to the home page. NEXT_PUBLIC_ALLOW_SIGNUP=true -NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS=true +NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS=false From 15b5f31a7494619bc88cd5715c2bbf1b46cffa83 Mon Sep 17 00:00:00 2001 From: Thanh Vu Date: Thu, 1 Jun 2023 23:54:27 +0700 Subject: [PATCH 03/42] fix: support ipv6 for nextjs --- apps/web/package.json | 4 ++-- apps/web/server/index.ts | 22 ++++++++++++++++++++++ apps/web/tsconfig.server.json | 11 +++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 apps/web/server/index.ts create mode 100644 apps/web/tsconfig.server.json diff --git a/apps/web/package.json b/apps/web/package.json index 349936c8a..41a6129f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", - "start": "next start", + "build": "next build && tsc --project tsconfig.server.json", + "start": "node dist/index.js", "lint": "next lint", "db-studio": "prisma db studio", "stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook" diff --git a/apps/web/server/index.ts b/apps/web/server/index.ts new file mode 100644 index 000000000..96c2a478c --- /dev/null +++ b/apps/web/server/index.ts @@ -0,0 +1,22 @@ +import { createServer } from 'http'; +import next from 'next'; +import { parse } from 'url'; + +const hostname = process.env.HOST || '[::]'; +const port = parseInt(process.env.PORT || '3000', 10); +const dev = process.env.NODE_ENV !== 'production'; +const app = next({ dev, hostname, port }); +const handle = app.getRequestHandler(); + +app.prepare().then(() => { + createServer((req, res) => { + const parsedUrl = parse(req.url!, true) + handle(req, res, parsedUrl) + }).listen(port); + + // eslint-disable-next-line no-console + console.log( + `> Server listening at http://${hostname}:${port} as ${dev ? 'development' : process.env.NODE_ENV + }` + ); +}); diff --git a/apps/web/tsconfig.server.json b/apps/web/tsconfig.server.json new file mode 100644 index 000000000..8cb34c903 --- /dev/null +++ b/apps/web/tsconfig.server.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist", + "target": "es2017", + "isolatedModules": false, + "noEmit": false + }, + "include": ["server/**/*.ts"] +} From 054480500f9d4f167d9ff0d4a0f960c201d6ee5c Mon Sep 17 00:00:00 2001 From: Thanh Vu Date: Fri, 2 Jun 2023 00:11:13 +0700 Subject: [PATCH 04/42] fix: custom nextjs server --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 41a6129f3..235ee57d8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev", "build": "next build && tsc --project tsconfig.server.json", - "start": "node dist/index.js", + "start": "node ./dist/index.js", "lint": "next lint", "db-studio": "prisma db studio", "stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook" From bbedd6d3ded55100bca6d5aa6616fdf20264891f Mon Sep 17 00:00:00 2001 From: Thanh Vu Date: Fri, 2 Jun 2023 00:19:08 +0700 Subject: [PATCH 05/42] fix: add custom nextjs server to docker --- docker/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5b19b2726..5ef51605c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -42,6 +42,7 @@ COPY --from=production_deps --chown=nextjs:nodejs /app/package-lock.json ./packa COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./package.json COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./.next +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/dist ./dist EXPOSE 3000 From 20b618c70f78fdd33c86494e4787fa586d63ec9c Mon Sep 17 00:00:00 2001 From: Thanh Vu Date: Fri, 2 Jun 2023 14:00:54 +0700 Subject: [PATCH 06/42] docs: update troubleshooting for IPv6 --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 0861312fb..17e594ea1 100644 --- a/README.md +++ b/README.md @@ -198,3 +198,32 @@ Want to create a production ready docker image? Follow these steps: - Docker support - One-Click-Deploy on Render.com Deploy + +# Troubleshooting + +## Support IPv6 + +In case you are deploying to a cluster that uses only IPv6. You can use a custom command to pass a parameter to the NextJS start command + +For local docker run + +```bash +docker run -it documenso:latest npm run start -- -H :: +``` + +For k8s or docker-compose + +```yaml +containers: + - name: documenso + image: documenso:latest + imagePullPolicy: IfNotPresent + command: + - npm + args: + - run + - start + - -- + - -H + - '::' +``` From 3caa01ab535f26f4425a464f3741aa27c3119f2a Mon Sep 17 00:00:00 2001 From: Thanh Vu Date: Fri, 2 Jun 2023 14:01:03 +0700 Subject: [PATCH 07/42] Revert "fix: add custom nextjs server to docker" This reverts commit 5dbe7b26286234db542921d9ded000c522c9a31e. --- docker/Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5ef51605c..5b19b2726 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -42,7 +42,6 @@ COPY --from=production_deps --chown=nextjs:nodejs /app/package-lock.json ./packa COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./package.json COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./.next -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/dist ./dist EXPOSE 3000 From db99bf3674691e4454937806b06b073a16b895d6 Mon Sep 17 00:00:00 2001 From: Thanh Vu Date: Fri, 2 Jun 2023 14:01:24 +0700 Subject: [PATCH 08/42] Revert "fix: custom nextjs server" This reverts commit 8f9a5f4ec7d834970a3e2b0778ce94218c997a8f. --- apps/web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 235ee57d8..41a6129f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev", "build": "next build && tsc --project tsconfig.server.json", - "start": "node ./dist/index.js", + "start": "node dist/index.js", "lint": "next lint", "db-studio": "prisma db studio", "stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook" From c41007e0265d02ef0868323ee96da016c19210c1 Mon Sep 17 00:00:00 2001 From: Thanh Vu Date: Fri, 2 Jun 2023 14:01:40 +0700 Subject: [PATCH 09/42] Revert "fix: support ipv6 for nextjs" This reverts commit f9de6671e0aa29e25e872a80aa334d3319e3e522. --- apps/web/package.json | 4 ++-- apps/web/server/index.ts | 22 ---------------------- apps/web/tsconfig.server.json | 11 ----------- 3 files changed, 2 insertions(+), 35 deletions(-) delete mode 100644 apps/web/server/index.ts delete mode 100644 apps/web/tsconfig.server.json diff --git a/apps/web/package.json b/apps/web/package.json index 41a6129f3..349936c8a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build && tsc --project tsconfig.server.json", - "start": "node dist/index.js", + "build": "next build", + "start": "next start", "lint": "next lint", "db-studio": "prisma db studio", "stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook" diff --git a/apps/web/server/index.ts b/apps/web/server/index.ts deleted file mode 100644 index 96c2a478c..000000000 --- a/apps/web/server/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createServer } from 'http'; -import next from 'next'; -import { parse } from 'url'; - -const hostname = process.env.HOST || '[::]'; -const port = parseInt(process.env.PORT || '3000', 10); -const dev = process.env.NODE_ENV !== 'production'; -const app = next({ dev, hostname, port }); -const handle = app.getRequestHandler(); - -app.prepare().then(() => { - createServer((req, res) => { - const parsedUrl = parse(req.url!, true) - handle(req, res, parsedUrl) - }).listen(port); - - // eslint-disable-next-line no-console - console.log( - `> Server listening at http://${hostname}:${port} as ${dev ? 'development' : process.env.NODE_ENV - }` - ); -}); diff --git a/apps/web/tsconfig.server.json b/apps/web/tsconfig.server.json deleted file mode 100644 index 8cb34c903..000000000 --- a/apps/web/tsconfig.server.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "outDir": "dist", - "target": "es2017", - "isolatedModules": false, - "noEmit": false - }, - "include": ["server/**/*.ts"] -} From 37c4e68aacded543d537cf769492f0477d63cc02 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 2 Jun 2023 20:01:10 +0000 Subject: [PATCH 10/42] Fix typo in contributing.md --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46768040d..5aeb61c1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ The development branch is main. All pull request should be made aga [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device. 2. Create a new branch: -- Create a new branch (include the issue id and somthing readable): +- Create a new branch (include the issue id and something readable): ```sh git checkout -b doc-999-my-feature-or-fix From a1bb360b6f0a14d87866180051ae4ba859625a95 Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Sat, 3 Jun 2023 21:58:14 +0900 Subject: [PATCH 11/42] Fix typo in pdf-editor.tsx postion -> position --- apps/web/components/editor/pdf-editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/components/editor/pdf-editor.tsx b/apps/web/components/editor/pdf-editor.tsx index dd9418a24..62fb9ade0 100644 --- a/apps/web/components/editor/pdf-editor.tsx +++ b/apps/web/components/editor/pdf-editor.tsx @@ -30,7 +30,7 @@ export default function PDFEditor(props: any) { movedField.positionY = position.y.toFixed(0); createOrUpdateField(props.document, movedField); - // no instant redraw neccessary, postion information for saving or later rerender is enough + // no instant redraw neccessary, position information for saving or later rerender is enough // setFields(newFields); } From effe781ce7ae0cd0c886bbd35b40f521801cd0e2 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Mon, 5 Jun 2023 12:33:08 +0100 Subject: [PATCH 12/42] chore: fix readme Product Hunt Badges Product Hunt is over, its probably better to move it into its own section. also added product of the day! --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 17e594ea1..f99206c54 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,3 @@ -
-

- We are LIVE on Product Hunt. Come say hi.. -

-Documenso - The Open Source DocuSign Alternative. | Product Hunt -
-

Documenso Logo @@ -63,6 +56,13 @@ Signing documents digitally is fast, easy and should be best practice for every document signed worldwide. This is technically quite easy today, but it also introduces a new party to every signature: The signing tool providers. While this is not a problem in itself, it should make us think about how we want these providers of trust to work. Documenso aims to be the world's most trusted document signing tool. This trust is built by empowering you to self-host Documenso and review how it works under the hood. Join us in creating the next generation of open trust infrastructure. +## Recognition + + +Documenso - The open source DocuSign alternative | Product Hunt +Documenso - The Open Source DocuSign Alternative. | Product Hunt + + ## Community and Next Steps 🎯 The current project goal is to [release a production ready version](https://github.com/documenso/documenso/milestone/1) for self-hosting as soon as possible. If you want to help making that happen you can: From 447bf0cb76c543c0e1c598dac22eb0da3d0fe7ea Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 12:23:52 +0000 Subject: [PATCH 13/42] Add password reset to prisma schema --- .../migration.sql | 15 +++++++++ packages/prisma/schema.prisma | 31 ++++++++++++------- 2 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 packages/prisma/migrations/20230605122017_password_reset/migration.sql 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/schema.prisma b/packages/prisma/schema.prisma index 33fae736d..c702f244f 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,11 @@ 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()) + userId Int + User User @relation(fields: [userId], references: [id]) +} From 002b22b1a88d52823ebea215ae074a6d6b9b8ff8 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 12:50:11 +0000 Subject: [PATCH 14/42] Add forgot password page --- apps/web/components/forgot-password.tsx | 77 +++++++++++++++++++++++++ apps/web/components/login.tsx | 6 +- apps/web/pages/forgot-password.tsx | 34 +++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 apps/web/components/forgot-password.tsx create mode 100644 apps/web/pages/forgot-password.tsx diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx new file mode 100644 index 000000000..30a7eb036 --- /dev/null +++ b/apps/web/components/forgot-password.tsx @@ -0,0 +1,77 @@ +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"; + +interface IResetPassword { + email: string; +} + +export default function ForgotPassword(props: any) { + const methods = useForm(); + const { register, formState, resetField } = methods; + + const onSubmit = async (values: IResetPassword) => { + resetField("email"); + + console.log(values); + }; + + return ( + <> +

+
+
+ +

+ Forgot Password? +

+

+ No worries, we'll send you reset instructions. +

+
+ +
+ +
+
+ + +
+
+ +
+ +
+
+ +
+ + 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) {
diff --git a/apps/web/pages/forgot-password.tsx b/apps/web/pages/forgot-password.tsx new file mode 100644 index 000000000..ffcf6e470 --- /dev/null +++ b/apps/web/pages/forgot-password.tsx @@ -0,0 +1,34 @@ +import Head from "next/head"; +import { getUserFromToken } from "@documenso/lib/server"; +import ForgotPassword from "../components/forgot-password"; + +export default function ForgotPasswordPage(props: any) { + 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, + }, + }; + + const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP === "true"; + + return { + props: { + ALLOW_SIGNUP, + }, + }; +} From 8293b5019530489044a0e53ff513fb32ba161b15 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 13:05:25 +0000 Subject: [PATCH 15/42] Create reset password token for user --- apps/web/components/forgot-password.tsx | 16 ++++++++ apps/web/pages/api/auth/forgot-password.ts | 43 ++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 apps/web/pages/api/auth/forgot-password.ts diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx index 30a7eb036..503c7bbd1 100644 --- a/apps/web/components/forgot-password.tsx +++ b/apps/web/components/forgot-password.tsx @@ -3,6 +3,7 @@ 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 IResetPassword { email: string; @@ -13,6 +14,21 @@ export default function ForgotPassword(props: any) { const { register, formState, resetField } = methods; const onSubmit = async (values: IResetPassword) => { + 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 :/", + } + ); + resetField("email"); console.log(values); 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..1a861362f --- /dev/null +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -0,0 +1,43 @@ +import { NextApiRequest, NextApiResponse } from "next"; +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 || !cleanEmail.includes("@")) { + res.status(422).json({ message: "Invalid email" }); + return; + } + + const user = await prisma.user.findFirst({ + where: { + email: cleanEmail, + }, + }); + + if (!user) { + return res.status(400).json({ message: "No user found with this email." }); + } + + const token = crypto.randomBytes(64).toString("hex"); + + const passwordResetToken = await prisma.passwordResetToken.create({ + data: { + token, + userId: user.id, + }, + }); + + console.log(passwordResetToken); + + // TODO: Send token to user via email + + res.status(201).end(); +} + +export default defaultHandler({ + POST: Promise.resolve({ default: defaultResponder(postHandler) }), +}); From 66b529a841f2dda36cfc110b5730cea06a7c183d Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 13:44:22 +0000 Subject: [PATCH 16/42] feat: send reset password email --- apps/web/pages/api/auth/forgot-password.ts | 5 +-- packages/lib/mail/baseTemplate.ts | 3 +- packages/lib/mail/index.ts | 2 + packages/lib/mail/resetPasswordTemplate.ts | 46 ++++++++++++++++++++++ packages/lib/mail/sendMail.ts | 1 - packages/lib/mail/sendResetPassword.ts | 17 ++++++++ 6 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 packages/lib/mail/resetPasswordTemplate.ts create mode 100644 packages/lib/mail/sendResetPassword.ts diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index 1a861362f..221be3d1c 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -1,4 +1,5 @@ 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"; @@ -31,9 +32,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { }, }); - console.log(passwordResetToken); - - // TODO: Send token to user via email + await sendResetPassword(user, passwordResetToken.token); res.status(201).end(); } 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..8388608c6 100644 --- a/packages/lib/mail/index.ts +++ b/packages/lib/mail/index.ts @@ -2,3 +2,5 @@ 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"; 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..fd918b470 --- /dev/null +++ b/packages/lib/mail/sendResetPassword.ts @@ -0,0 +1,17 @@ +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}/api/auth/reset/${token}`, + "Reset Your Password" + ) + ).catch((err) => { + throw err; + }); +}; From 8dc9c9d72dfcc8c9833885312554fa524612df34 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 14:17:45 +0000 Subject: [PATCH 17/42] Add reset password page --- apps/web/components/forgot-password.tsx | 6 +- apps/web/components/reset-password.tsx | 93 +++++++++++++++++++++++ apps/web/pages/api/auth/reset-password.ts | 42 ++++++++++ apps/web/pages/auth/reset.tsx | 34 +++++++++ apps/web/pages/forgot-password.tsx | 2 +- 5 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 apps/web/components/reset-password.tsx create mode 100644 apps/web/pages/api/auth/reset-password.ts create mode 100644 apps/web/pages/auth/reset.tsx diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx index 503c7bbd1..7a7ec0356 100644 --- a/apps/web/components/forgot-password.tsx +++ b/apps/web/components/forgot-password.tsx @@ -5,15 +5,15 @@ import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { FormProvider, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; -interface IResetPassword { +interface IForgotPassword { email: string; } export default function ForgotPassword(props: any) { - const methods = useForm(); + const methods = useForm(); const { register, formState, resetField } = methods; - const onSubmit = async (values: IResetPassword) => { + const onSubmit = async (values: IForgotPassword) => { await toast.promise( fetch(`/api/auth/forgot-password`, { method: "POST", diff --git a/apps/web/components/reset-password.tsx b/apps/web/components/reset-password.tsx new file mode 100644 index 000000000..88f3cb4d0 --- /dev/null +++ b/apps/web/components/reset-password.tsx @@ -0,0 +1,93 @@ +import Link from "next/link"; +import { Button } from "@documenso/ui"; +import Logo from "./logo"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { FormProvider, useForm, useWatch } from "react-hook-form"; +import { toast } from "react-hot-toast"; + +interface IResetPassword { + password: string; + confirmPassword: string; +} + +export default function ResetPassword(props: any) { + const methods = useForm(); + const { register, formState, watch } = methods; + const password = watch("password", ""); + + const onSubmit = async (values: IResetPassword) => { + console.log(values); + }; + + return ( + <> +
+
+
+ +

+ Reset Password +

+

Please chose your new password

+
+ +
+ +
+
+ + +
+
+ + value === password || "The passwords do not match", + })} + 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" + /> +
+
+ +
+ +
+
+ +
+ + Back to log in +
+ +
+
+
+
+
+ + ); +} 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..221be3d1c --- /dev/null +++ b/apps/web/pages/api/auth/reset-password.ts @@ -0,0 +1,42 @@ +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 || !cleanEmail.includes("@")) { + res.status(422).json({ message: "Invalid email" }); + return; + } + + const user = await prisma.user.findFirst({ + where: { + email: cleanEmail, + }, + }); + + if (!user) { + return res.status(400).json({ message: "No user found with this email." }); + } + + const token = crypto.randomBytes(64).toString("hex"); + + const passwordResetToken = await prisma.passwordResetToken.create({ + data: { + token, + userId: user.id, + }, + }); + + await sendResetPassword(user, passwordResetToken.token); + + res.status(201).end(); +} + +export default defaultHandler({ + POST: Promise.resolve({ default: defaultResponder(postHandler) }), +}); diff --git a/apps/web/pages/auth/reset.tsx b/apps/web/pages/auth/reset.tsx new file mode 100644 index 000000000..efbb4372b --- /dev/null +++ b/apps/web/pages/auth/reset.tsx @@ -0,0 +1,34 @@ +import Head from "next/head"; +import { getUserFromToken } from "@documenso/lib/server"; +import ResetPassword from "../../components/reset-password"; + +export default function ResetPasswordPage(props: any) { + 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, + }, + }; + + const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP === "true"; + + return { + props: { + ALLOW_SIGNUP, + }, + }; +} diff --git a/apps/web/pages/forgot-password.tsx b/apps/web/pages/forgot-password.tsx index ffcf6e470..7ecf29881 100644 --- a/apps/web/pages/forgot-password.tsx +++ b/apps/web/pages/forgot-password.tsx @@ -6,7 +6,7 @@ export default function ForgotPasswordPage(props: any) { return ( <> - Reset Password | Documenso + Forgot Password | Documenso From 6e2b05f8354eecc2a679240a6fd85d838d67c205 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 14:36:20 +0000 Subject: [PATCH 18/42] Change password in database to new reset password --- apps/web/components/reset-password.tsx | 37 ++++++++++++++ apps/web/pages/api/auth/forgot-password.ts | 3 +- apps/web/pages/api/auth/reset-password.ts | 49 ++++++++++++------- .../auth/{reset.tsx => reset/[token].tsx} | 2 +- packages/lib/mail/sendResetPassword.ts | 5 +- 5 files changed, 71 insertions(+), 25 deletions(-) rename apps/web/pages/auth/{reset.tsx => reset/[token].tsx} (91%) diff --git a/apps/web/components/reset-password.tsx b/apps/web/components/reset-password.tsx index 88f3cb4d0..27e214574 100644 --- a/apps/web/components/reset-password.tsx +++ b/apps/web/components/reset-password.tsx @@ -1,4 +1,5 @@ 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"; @@ -11,14 +12,50 @@ interface IResetPassword { } export default function ResetPassword(props: any) { + const router = useRouter(); + const { token } = router.query; + const methods = useForm(); const { register, formState, watch } = methods; const password = watch("password", ""); const onSubmit = async (values: IResetPassword) => { + await toast.promise( + fetch(`/api/auth/reset-password`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ password: values.password, token }), + }), + { + loading: "Resetting...", + success: `Reset password successful`, + error: "Could not reset password :/", + } + ); + console.log(values); }; + if (!token) { + return ( +
+
+
+ +

+ Reset Password +

+

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

+
+
+
+ ); + } + return ( <>
diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index 221be3d1c..ef2b218d7 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -24,7 +24,6 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { } const token = crypto.randomBytes(64).toString("hex"); - const passwordResetToken = await prisma.passwordResetToken.create({ data: { token, @@ -34,7 +33,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { await sendResetPassword(user, passwordResetToken.token); - res.status(201).end(); + res.status(200).json({ message: "Password reset email sent." }); } export default defaultHandler({ diff --git a/apps/web/pages/api/auth/reset-password.ts b/apps/web/pages/api/auth/reset-password.ts index 221be3d1c..ad6ef7a49 100644 --- a/apps/web/pages/api/auth/reset-password.ts +++ b/apps/web/pages/api/auth/reset-password.ts @@ -1,40 +1,53 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { hashPassword } from "@documenso/lib/auth"; 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(); + const { token, password } = req.body; - if (!cleanEmail || !cleanEmail.includes("@")) { - res.status(422).json({ message: "Invalid email" }); + if (!token) { + res.status(422).json({ message: "Invalid token" }); return; } - const user = await prisma.user.findFirst({ + const foundToken = await prisma.passwordResetToken.findUnique({ where: { - email: cleanEmail, + token, + }, + include: { + User: true, }, }); - if (!user) { - return res.status(400).json({ message: "No user found with this email." }); + if (!foundToken) { + return res.status(400).json({ message: "Invalid token." }); } - const token = crypto.randomBytes(64).toString("hex"); + const hashedPassword = await hashPassword(password); - const passwordResetToken = await prisma.passwordResetToken.create({ - data: { - token, - userId: user.id, - }, - }); + const transaction = await prisma.$transaction([ + prisma.user.update({ + where: { + id: foundToken.userId, + }, + data: { + password: hashedPassword, + }, + }), + prisma.passwordResetToken.delete({ + where: { + token, + }, + }), + ]); - await sendResetPassword(user, passwordResetToken.token); + if (!transaction) { + return res.status(500).json({ message: "Error resetting password." }); + } - res.status(201).end(); + res.status(200).json({ message: "Password reset successful." }); } export default defaultHandler({ diff --git a/apps/web/pages/auth/reset.tsx b/apps/web/pages/auth/reset/[token].tsx similarity index 91% rename from apps/web/pages/auth/reset.tsx rename to apps/web/pages/auth/reset/[token].tsx index efbb4372b..fffe7c3c7 100644 --- a/apps/web/pages/auth/reset.tsx +++ b/apps/web/pages/auth/reset/[token].tsx @@ -1,6 +1,6 @@ import Head from "next/head"; import { getUserFromToken } from "@documenso/lib/server"; -import ResetPassword from "../../components/reset-password"; +import ResetPassword from "../../../components/reset-password"; export default function ResetPasswordPage(props: any) { return ( diff --git a/packages/lib/mail/sendResetPassword.ts b/packages/lib/mail/sendResetPassword.ts index fd918b470..32e098a4c 100644 --- a/packages/lib/mail/sendResetPassword.ts +++ b/packages/lib/mail/sendResetPassword.ts @@ -7,10 +7,7 @@ export const sendResetPassword = async (user: User, token: string) => { await sendMail( user.email, "Forgot password?", - resetPasswordTemplate( - `${NEXT_PUBLIC_WEBAPP_URL}/api/auth/reset/${token}`, - "Reset Your Password" - ) + resetPasswordTemplate(`${NEXT_PUBLIC_WEBAPP_URL}/auth/reset/${token}`, "Reset Your Password") ).catch((err) => { throw err; }); From 7c30ee0c3e1af084a877774d3429a0e27143bd97 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 14:47:10 +0000 Subject: [PATCH 19/42] Redirect to /login on password reset --- apps/web/components/reset-password.tsx | 28 ++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/web/components/reset-password.tsx b/apps/web/components/reset-password.tsx index 27e214574..d4745ee24 100644 --- a/apps/web/components/reset-password.tsx +++ b/apps/web/components/reset-password.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; import { Button } from "@documenso/ui"; @@ -19,8 +20,10 @@ export default function ResetPassword(props: any) { const { register, formState, watch } = methods; const password = watch("password", ""); + const [resetSuccessful, setResetSuccessful] = useState(false); + const onSubmit = async (values: IResetPassword) => { - await toast.promise( + const response = await toast.promise( fetch(`/api/auth/reset-password`, { method: "POST", headers: { @@ -35,7 +38,12 @@ export default function ResetPassword(props: any) { } ); - console.log(values); + if (response.ok) { + setResetSuccessful(true); + setTimeout(() => { + router.push("/login"); + }, 2000); + } }; if (!token) { @@ -56,6 +64,22 @@ export default function ResetPassword(props: any) { ); } + if (resetSuccessful) { + return ( +
+
+
+ +

+ Reset Password +

+

Your password has been reset.

+
+
+
+ ); + } + return ( <>
From c47e01b2b8d3b3236c456f07bfedef12829a9293 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 14:59:50 +0000 Subject: [PATCH 20/42] Display sucessful password reset request --- apps/web/components/forgot-password.tsx | 39 ++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx index 7a7ec0356..bb55bfb66 100644 --- a/apps/web/components/forgot-password.tsx +++ b/apps/web/components/forgot-password.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import Link from "next/link"; import { Button } from "@documenso/ui"; import Logo from "./logo"; @@ -9,12 +10,14 @@ interface IForgotPassword { email: string; } -export default function ForgotPassword(props: any) { +export default function ForgotPassword() { const methods = useForm(); const { register, formState, resetField } = methods; + const [resetSuccessful, setResetSuccessful] = useState(false); + const onSubmit = async (values: IForgotPassword) => { - await toast.promise( + const response = await toast.promise( fetch(`/api/auth/forgot-password`, { method: "POST", headers: { @@ -29,11 +32,39 @@ export default function ForgotPassword(props: any) { } ); - resetField("email"); + if (response.ok) { + setResetSuccessful(true); + } - console.log(values); + resetField("email"); }; + if (resetSuccessful) { + return ( +
+
+
+ +

+ Reset Password +

+

+ Please check your email for reset instructions. +

+
+
+ +
+ + Back to log in +
+ +
+
+
+ ); + } + return ( <>
From 5d2349086d3a90e47ba0f50dd9c289db7e3b225e Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 15:33:27 +0000 Subject: [PATCH 21/42] Send email on password reset complete --- apps/web/pages/api/auth/forgot-password.ts | 2 +- apps/web/pages/api/auth/reset-password.ts | 4 +- apps/web/pages/auth/reset.tsx | 0 packages/lib/mail/index.ts | 2 + .../lib/mail/resetPasswordSuccessTemplate.ts | 51 +++++++++++++++++++ .../lib/mail/sendResetPasswordSuccessMail.ts | 11 ++++ packages/lib/mail/signingCompleteTemplate.ts | 1 - 7 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 apps/web/pages/auth/reset.tsx create mode 100644 packages/lib/mail/resetPasswordSuccessTemplate.ts create mode 100644 packages/lib/mail/sendResetPasswordSuccessMail.ts diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index ef2b218d7..e30eeed36 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -1,5 +1,5 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { sendResetPassword } from "@documenso/lib/mail"; +import { sendResetPassword, sendResetPasswordSuccessMail } from "@documenso/lib/mail"; import { defaultHandler, defaultResponder } from "@documenso/lib/server"; import prisma from "@documenso/prisma"; import crypto from "crypto"; diff --git a/apps/web/pages/api/auth/reset-password.ts b/apps/web/pages/api/auth/reset-password.ts index ad6ef7a49..4bf2e5306 100644 --- a/apps/web/pages/api/auth/reset-password.ts +++ b/apps/web/pages/api/auth/reset-password.ts @@ -1,6 +1,6 @@ import { NextApiRequest, NextApiResponse } from "next"; import { hashPassword } from "@documenso/lib/auth"; -import { sendResetPassword } from "@documenso/lib/mail"; +import { sendResetPasswordSuccessMail } from "@documenso/lib/mail"; import { defaultHandler, defaultResponder } from "@documenso/lib/server"; import prisma from "@documenso/prisma"; @@ -47,6 +47,8 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { return res.status(500).json({ message: "Error resetting password." }); } + await sendResetPasswordSuccessMail(foundToken.User); + res.status(200).json({ message: "Password reset successful." }); } diff --git a/apps/web/pages/auth/reset.tsx b/apps/web/pages/auth/reset.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/lib/mail/index.ts b/packages/lib/mail/index.ts index 8388608c6..e4d66dc44 100644 --- a/packages/lib/mail/index.ts +++ b/packages/lib/mail/index.ts @@ -4,3 +4,5 @@ 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..44afcc119 --- /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. +

+ +

+ If you did not ask to change your password 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/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 = ` From e9cee23c15d509d2562748b1b40b7db445004a46 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 15:52:00 +0000 Subject: [PATCH 22/42] Error handling for invalid users --- apps/web/components/forgot-password.tsx | 19 +++++++++++++++++++ apps/web/pages/api/auth/forgot-password.ts | 21 ++++++++++++++------- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx index bb55bfb66..40d0af98a 100644 --- a/apps/web/components/forgot-password.tsx +++ b/apps/web/components/forgot-password.tsx @@ -29,9 +29,28 @@ export default function ForgotPassword() { loading: "Sending...", success: `Reset link sent. `, error: "Could not send reset link :/", + }, + { + style: { + minWidth: "200px", + }, } ); + if (!response.ok) { + toast.dismiss(); + + if (response.status == 400 || response.status == 404) { + toast.error("Email address not found."); + } + + if (response.status == 500) { + toast.error("Something went wrong."); + } + + return; + } + if (response.ok) { setResetSuccessful(true); } diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index e30eeed36..058a2d3b6 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -20,16 +20,23 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { }); if (!user) { - return res.status(400).json({ message: "No user found with this email." }); + return res.status(404).json({ message: "No user found with this email." }); } const token = crypto.randomBytes(64).toString("hex"); - const passwordResetToken = await prisma.passwordResetToken.create({ - data: { - token, - userId: user.id, - }, - }); + + let passwordResetToken; + + try { + passwordResetToken = await prisma.passwordResetToken.create({ + data: { + token, + userId: user.id, + }, + }); + } catch (error) { + return res.status(500).json({ message: "Error saving token." }); + } await sendResetPassword(user, passwordResetToken.token); From 4136811e32995eab97a5fb785f2190dfad9057c2 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 16:01:01 +0000 Subject: [PATCH 23/42] Avoid consecutive password reset requests --- apps/web/components/forgot-password.tsx | 11 +++++------ apps/web/components/reset-password.tsx | 2 +- apps/web/pages/api/auth/forgot-password.ts | 16 +++++++++++++++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx index 40d0af98a..2f4250c05 100644 --- a/apps/web/components/forgot-password.tsx +++ b/apps/web/components/forgot-password.tsx @@ -29,21 +29,20 @@ export default function ForgotPassword() { loading: "Sending...", success: `Reset link sent. `, error: "Could not send reset link :/", - }, - { - style: { - minWidth: "200px", - }, } ); if (!response.ok) { toast.dismiss(); - if (response.status == 400 || response.status == 404) { + 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."); } diff --git a/apps/web/components/reset-password.tsx b/apps/web/components/reset-password.tsx index d4745ee24..f55a7628c 100644 --- a/apps/web/components/reset-password.tsx +++ b/apps/web/components/reset-password.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { Button } from "@documenso/ui"; import Logo from "./logo"; import { ArrowLeftIcon } from "@heroicons/react/24/outline"; -import { FormProvider, useForm, useWatch } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; interface IResetPassword { diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index 058a2d3b6..42d904fb7 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -23,10 +23,24 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { return res.status(404).json({ message: "No user found with this email." }); } + const existingToken = await prisma.passwordResetToken.findFirst({ + where: { + userId: user.id, + createdAt: { + gte: new Date(Date.now() - 1000 * 60 * 60), + }, + }, + }); + + if (existingToken) { + return res + .status(400) + .json({ message: "A password reset has already been requested. Please check your email." }); + } + const token = crypto.randomBytes(64).toString("hex"); let passwordResetToken; - try { passwordResetToken = await prisma.passwordResetToken.create({ data: { From 2b9a2ff250626a1ddd76a77a132d483ed0899328 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 16:36:16 +0000 Subject: [PATCH 24/42] Avoid user from setting the same old password --- apps/web/components/reset-password.tsx | 18 ++++++++++++++++++ apps/web/pages/api/auth/forgot-password.ts | 2 +- apps/web/pages/api/auth/reset-password.ts | 12 ++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/web/components/reset-password.tsx b/apps/web/components/reset-password.tsx index f55a7628c..7cc38ce42 100644 --- a/apps/web/components/reset-password.tsx +++ b/apps/web/components/reset-password.tsx @@ -38,6 +38,24 @@ export default function ResetPassword(props: any) { } ); + if (!response.ok) { + toast.dismiss(); + + if (response.status == 404) { + toast.error("Invalid Token"); + } + + if (response.status == 400) { + toast.error("New password must be different"); + } + + if (response.status == 500) { + toast.error("Something went wrong."); + } + + return; + } + if (response.ok) { setResetSuccessful(true); setTimeout(() => { diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index 42d904fb7..7edab5aca 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -1,5 +1,5 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { sendResetPassword, sendResetPasswordSuccessMail } from "@documenso/lib/mail"; +import { sendResetPassword } from "@documenso/lib/mail"; import { defaultHandler, defaultResponder } from "@documenso/lib/server"; import prisma from "@documenso/prisma"; import crypto from "crypto"; diff --git a/apps/web/pages/api/auth/reset-password.ts b/apps/web/pages/api/auth/reset-password.ts index 4bf2e5306..c98de4809 100644 --- a/apps/web/pages/api/auth/reset-password.ts +++ b/apps/web/pages/api/auth/reset-password.ts @@ -1,5 +1,5 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { hashPassword } from "@documenso/lib/auth"; +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"; @@ -22,7 +22,15 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { }); if (!foundToken) { - return res.status(400).json({ message: "Invalid token." }); + return res.status(404).json({ message: "Invalid token." }); + } + + const isSamePassword = await verifyPassword(password, foundToken.User.password!); + + if (isSamePassword) { + return res + .status(400) + .json({ message: "New password must be different from the current password." }); } const hashedPassword = await hashPassword(password); From 3a0648c85d5e51a291393bfda9d63a8ea6d7fa95 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 16:54:12 +0000 Subject: [PATCH 25/42] Expire token after 1 hour --- apps/web/components/forgot-password.tsx | 2 +- apps/web/components/reset-password.tsx | 16 ++-------------- apps/web/pages/api/auth/forgot-password.ts | 11 ++++++----- apps/web/pages/api/auth/reset-password.ts | 10 +++++++--- .../migration.sql | 8 ++++++++ packages/prisma/schema.prisma | 1 + 6 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 packages/prisma/migrations/20230605164015_expire_password_reset_token/migration.sql diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx index 2f4250c05..cd7e4a99c 100644 --- a/apps/web/components/forgot-password.tsx +++ b/apps/web/components/forgot-password.tsx @@ -27,7 +27,7 @@ export default function ForgotPassword() { }), { loading: "Sending...", - success: `Reset link sent. `, + success: "Reset link sent.", error: "Could not send reset link :/", } ); diff --git a/apps/web/components/reset-password.tsx b/apps/web/components/reset-password.tsx index 7cc38ce42..3bf4dcbcf 100644 --- a/apps/web/components/reset-password.tsx +++ b/apps/web/components/reset-password.tsx @@ -40,20 +40,8 @@ export default function ResetPassword(props: any) { if (!response.ok) { toast.dismiss(); - - if (response.status == 404) { - toast.error("Invalid Token"); - } - - if (response.status == 400) { - toast.error("New password must be different"); - } - - if (response.status == 500) { - toast.error("Something went wrong."); - } - - return; + const error = await response.json(); + toast.error(error.message); } if (response.ok) { diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index 7edab5aca..1c77c6df6 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -20,7 +20,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { }); if (!user) { - return res.status(404).json({ message: "No user found with this email." }); + return res.status(404).json({ message: "No user found with this email" }); } const existingToken = await prisma.passwordResetToken.findFirst({ @@ -33,23 +33,24 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { }); if (existingToken) { - return res - .status(400) - .json({ message: "A password reset has already been requested. Please check your email." }); + return res.status(400).json({ message: "Password reset requested." }); } const token = crypto.randomBytes(64).toString("hex"); + const expiry = new Date(); + expiry.setHours(expiry.getHours() + 1); // 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: "Error saving token." }); + return res.status(500).json({ message: "Something went wrong" }); } await sendResetPassword(user, passwordResetToken.token); diff --git a/apps/web/pages/api/auth/reset-password.ts b/apps/web/pages/api/auth/reset-password.ts index c98de4809..89ed6108c 100644 --- a/apps/web/pages/api/auth/reset-password.ts +++ b/apps/web/pages/api/auth/reset-password.ts @@ -25,12 +25,16 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { 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 from the current password." }); + return res.status(400).json({ message: "New password must be different" }); } const hashedPassword = await hashPassword(password); 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 c702f244f..fc8463c9b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -164,6 +164,7 @@ 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]) } From 79bd410687c286b95b4b970df2832bd498431a5d Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 17:15:41 +0000 Subject: [PATCH 26/42] Remove tokens on successful password reset --- apps/web/pages/api/auth/reset-password.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/pages/api/auth/reset-password.ts b/apps/web/pages/api/auth/reset-password.ts index 89ed6108c..bc42e2339 100644 --- a/apps/web/pages/api/auth/reset-password.ts +++ b/apps/web/pages/api/auth/reset-password.ts @@ -48,9 +48,9 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { password: hashedPassword, }, }), - prisma.passwordResetToken.delete({ + prisma.passwordResetToken.deleteMany({ where: { - token, + userId: foundToken.userId, }, }), ]); From 9ff85273361d7ad39c0d5d59a2928e8057452c54 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Jun 2023 21:49:39 +0000 Subject: [PATCH 27/42] fix: expired slack link --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f99206c54..8a3382929 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Learn more »

- Slack + Slack · Website · @@ -22,7 +22,7 @@

- Join Documenso on Slack + Join Documenso on Slack Github Stars License Commits-per-month @@ -58,18 +58,16 @@ Signing documents digitally is fast, easy and should be best practice for every ## Recognition - Documenso - The open source DocuSign alternative | Product Hunt Documenso - The Open Source DocuSign Alternative. | Product Hunt - ## Community and Next Steps 🎯 The current project goal is to [release a production ready version](https://github.com/documenso/documenso/milestone/1) for self-hosting as soon as possible. If you want to help making that happen you can: - Check out the first source code release in this repository and test it - Tell us what you think in the current [Discussions](https://github.com/documenso/documenso/discussions) -- Join the [Slack Channel](https://join.slack.com/t/documenso/shared_invite/zt-1qwxxsvli-nDyojjt~wakhgBGl9JRl2w) for any questions and getting to know to other community members +- Join the [Slack Channel](https://join.slack.com/t/documenso/shared_invite/zt-1wrwmi601-Zcn1GpVOagcakE842fVCBQ) for any questions and getting to know to other community members - ⭐ the repository to help us raise awareness - Spread the word on Twitter, that Documenso is working towards a more open signing tool - Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release @@ -225,5 +223,5 @@ containers: - start - -- - -H - - '::' + - "::" ``` From 02f9c38e1e14eb0f6bb60fbf664a87b570f28561 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 7 Jun 2023 09:59:40 +0000 Subject: [PATCH 28/42] Replace slack link with documen.so/slack --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8a3382929..c4daabec1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Learn more »

- Slack + Slack · Website · @@ -22,7 +22,7 @@

- Join Documenso on Slack + Join Documenso on Slack Github Stars License Commits-per-month @@ -67,7 +67,7 @@ The current project goal is to [release a production ready version](https://g - Check out the first source code release in this repository and test it - Tell us what you think in the current [Discussions](https://github.com/documenso/documenso/discussions) -- Join the [Slack Channel](https://join.slack.com/t/documenso/shared_invite/zt-1wrwmi601-Zcn1GpVOagcakE842fVCBQ) for any questions and getting to know to other community members +- Join the [Slack Channel](https://documen.so/slack) for any questions and getting to know to other community members - ⭐ the repository to help us raise awareness - Spread the word on Twitter, that Documenso is working towards a more open signing tool - Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release From 7184c47ac4ba50638b3e9f18ad58f22400dc1689 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 7 Jun 2023 10:10:56 +0000 Subject: [PATCH 29/42] Rename component interfaces --- apps/web/components/forgot-password.tsx | 6 +++--- apps/web/components/reset-password.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx index cd7e4a99c..52dd96ce3 100644 --- a/apps/web/components/forgot-password.tsx +++ b/apps/web/components/forgot-password.tsx @@ -6,17 +6,17 @@ import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { FormProvider, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; -interface IForgotPassword { +interface ForgotPasswordForm { email: string; } export default function ForgotPassword() { - const methods = useForm(); + const methods = useForm(); const { register, formState, resetField } = methods; const [resetSuccessful, setResetSuccessful] = useState(false); - const onSubmit = async (values: IForgotPassword) => { + const onSubmit = async (values: ForgotPasswordForm) => { const response = await toast.promise( fetch(`/api/auth/forgot-password`, { method: "POST", diff --git a/apps/web/components/reset-password.tsx b/apps/web/components/reset-password.tsx index 3bf4dcbcf..16276ed4d 100644 --- a/apps/web/components/reset-password.tsx +++ b/apps/web/components/reset-password.tsx @@ -7,7 +7,7 @@ import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { FormProvider, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; -interface IResetPassword { +interface ForgotPasswordForm { password: string; confirmPassword: string; } @@ -16,13 +16,13 @@ export default function ResetPassword(props: any) { const router = useRouter(); const { token } = router.query; - const methods = useForm(); + const methods = useForm(); const { register, formState, watch } = methods; const password = watch("password", ""); const [resetSuccessful, setResetSuccessful] = useState(false); - const onSubmit = async (values: IResetPassword) => { + const onSubmit = async (values: ForgotPasswordForm) => { const response = await toast.promise( fetch(`/api/auth/reset-password`, { method: "POST", From f08836216ecb107ccc252eaafcb2ba6fb6ffc1f2 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 7 Jun 2023 10:12:05 +0000 Subject: [PATCH 30/42] Remove unused input fields --- apps/web/components/forgot-password.tsx | 1 - apps/web/components/reset-password.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/web/components/forgot-password.tsx b/apps/web/components/forgot-password.tsx index 52dd96ce3..a0eee29f4 100644 --- a/apps/web/components/forgot-password.tsx +++ b/apps/web/components/forgot-password.tsx @@ -98,7 +98,6 @@ export default function ForgotPassword() {

-
-