From 24d9906557ed7ed5644f4069da591c1b143a24a7 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 22 Nov 2023 15:03:15 +0200 Subject: [PATCH 001/466] feat: public api start --- package-lock.json | 226 +++++++++++++++++++++++++ packages/trpc/api-contract/contract.ts | 32 ++++ packages/trpc/package.json | 2 + packages/trpc/tsconfig.json | 5 +- 4 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 packages/trpc/api-contract/contract.ts diff --git a/package-lock.json b/package-lock.json index f7d0e2a5d..f1078e528 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5988,6 +5988,19 @@ "https://trpc.io/sponsor" ] }, + "node_modules/@ts-rest/core": { + "version": "3.30.5", + "resolved": "https://registry.npmjs.org/@ts-rest/core/-/core-3.30.5.tgz", + "integrity": "sha512-j2sgvk3x8wZiCyhB3ij0I287FgkngCGRHXFBxQ9HtZ9mxQuIIDfibi1yD/ydNvNif0pA6BDdASGQY1WjfqUC3g==", + "peerDependencies": { + "zod": "^3.22.3" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -19825,10 +19838,223 @@ "@trpc/next": "^10.36.0", "@trpc/react-query": "^10.36.0", "@trpc/server": "^10.36.0", + "@ts-rest/core": "^3.30.5", + "@ts-rest/next": "^3.30.5", "superjson": "^1.13.1", "zod": "^3.22.4" } }, + "packages/trpc/node_modules/@next/env": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.6.tgz", + "integrity": "sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw==", + "peer": true + }, + "packages/trpc/node_modules/@next/swc-darwin-arm64": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.6.tgz", + "integrity": "sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/trpc/node_modules/@next/swc-darwin-x64": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.6.tgz", + "integrity": "sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/trpc/node_modules/@next/swc-linux-arm64-gnu": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.6.tgz", + "integrity": "sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/trpc/node_modules/@next/swc-linux-arm64-musl": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.6.tgz", + "integrity": "sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/trpc/node_modules/@next/swc-linux-x64-gnu": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.6.tgz", + "integrity": "sha512-Mc2b4xiIWKXIhBy2NBTwOxGD3nHLmq4keFk+d4/WL5fMsB8XdJRdtUlL87SqVCTSaf1BRuQQf1HvXZcy+rq3Nw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/trpc/node_modules/@next/swc-linux-x64-musl": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.6.tgz", + "integrity": "sha512-CFHvP9Qz98NruJiUnCe61O6GveKKHpJLloXbDSWRhqhkJdZD2zU5hG+gtVJR//tyW897izuHpM6Gtf6+sNgJPQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/trpc/node_modules/@next/swc-win32-arm64-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.6.tgz", + "integrity": "sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/trpc/node_modules/@next/swc-win32-ia32-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.6.tgz", + "integrity": "sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/trpc/node_modules/@next/swc-win32-x64-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.6.tgz", + "integrity": "sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/trpc/node_modules/@ts-rest/next": { + "version": "3.30.5", + "resolved": "https://registry.npmjs.org/@ts-rest/next/-/next-3.30.5.tgz", + "integrity": "sha512-NasfUN7SnwcjJNbxvvcemC4fOv4f4IF5I14wVqQODN0HWPokkrta6XLuv0eKQJYdB32AS7VINQhls8Sj1AIN0g==", + "peerDependencies": { + "@ts-rest/core": "3.30.5", + "next": "^12.0.0 || ^13.0.0", + "zod": "^3.22.3" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "packages/trpc/node_modules/next": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/next/-/next-13.5.6.tgz", + "integrity": "sha512-Y2wTcTbO4WwEsVb4A8VSnOsG1I9ok+h74q0ZdxkwM3EODqrs4pasq7O0iUxbcS9VtWMicG7f3+HAj0r1+NtKSw==", + "peer": true, + "dependencies": { + "@next/env": "13.5.6", + "@swc/helpers": "0.5.2", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001406", + "postcss": "8.4.31", + "styled-jsx": "5.1.1", + "watchpack": "2.4.0" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=16.14.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "13.5.6", + "@next/swc-darwin-x64": "13.5.6", + "@next/swc-linux-arm64-gnu": "13.5.6", + "@next/swc-linux-arm64-musl": "13.5.6", + "@next/swc-linux-x64-gnu": "13.5.6", + "@next/swc-linux-x64-musl": "13.5.6", + "@next/swc-win32-arm64-msvc": "13.5.6", + "@next/swc-win32-ia32-msvc": "13.5.6", + "@next/swc-win32-x64-msvc": "13.5.6" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, "packages/tsconfig": { "name": "@documenso/tsconfig", "version": "0.0.0", diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts new file mode 100644 index 000000000..194b663f7 --- /dev/null +++ b/packages/trpc/api-contract/contract.ts @@ -0,0 +1,32 @@ +import { initContract } from '@ts-rest/core'; +import { z } from 'zod'; + +const c = initContract(); + +const GetDocumentsQuery = z.object({ + take: z.string().default('10'), + skip: z.string().default('0'), +}); + +const DocumentSchema = z.object({ + id: z.string(), + userId: z.number(), + title: z.string(), + status: z.string(), + documentDataId: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + completedAt: z.string(), +}); + +export const contract = c.router({ + getDocuments: { + method: 'GET', + path: '/documents', + query: GetDocumentsQuery, + responses: { + 200: DocumentSchema.array(), + }, + summary: 'Get all documents for a user', + }, +}); diff --git a/packages/trpc/package.json b/packages/trpc/package.json index b003509aa..f92b04e4c 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -17,6 +17,8 @@ "@trpc/next": "^10.36.0", "@trpc/react-query": "^10.36.0", "@trpc/server": "^10.36.0", + "@ts-rest/core": "^3.30.5", + "@ts-rest/next": "^3.30.5", "superjson": "^1.13.1", "zod": "^3.22.4" } diff --git a/packages/trpc/tsconfig.json b/packages/trpc/tsconfig.json index 4aefcb98c..dc21318a7 100644 --- a/packages/trpc/tsconfig.json +++ b/packages/trpc/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "@documenso/tsconfig/react-library.json", "include": ["."], - "exclude": ["dist", "build", "node_modules"] + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "strict": true, + } } From 4a6b3edc05bf66b809395fc085de215c0364eefc Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 22 Nov 2023 15:44:49 +0200 Subject: [PATCH 002/466] feat: get documents api route with pagination --- apps/web/src/pages/api/v1/[...ts-rest].tsx | 28 +++++++++++++++++++ .../server-only/public-api/get-documents.ts | 21 ++++++++++++++ packages/trpc/api-contract/contract.ts | 12 ++++---- packages/trpc/server/public-api/ts-rest.ts | 3 ++ 4 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/pages/api/v1/[...ts-rest].tsx create mode 100644 packages/lib/server-only/public-api/get-documents.ts create mode 100644 packages/trpc/server/public-api/ts-rest.ts diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx new file mode 100644 index 000000000..872d68e28 --- /dev/null +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -0,0 +1,28 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { getDocuments } from '@documenso/lib/server-only/public-api/get-documents'; +import { contract } from '@documenso/trpc/api-contract/contract'; +import { createNextRoute, createNextRouter } from '@documenso/trpc/server/public-api/ts-rest'; + +const router = createNextRoute(contract, { + getDocuments: async (args) => { + const page = Number(args.query.page) || 1; + const perPage = Number(args.query.perPage) || 10; + + const { documents, totalPages } = await getDocuments({ page, perPage }); + + return { + status: 200, + body: { + documents, + totalPages, + }, + }; + }, +}); + +const nextRouter = createNextRouter(contract, router); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await nextRouter(req, res); +} diff --git a/packages/lib/server-only/public-api/get-documents.ts b/packages/lib/server-only/public-api/get-documents.ts new file mode 100644 index 000000000..bbc3ab14c --- /dev/null +++ b/packages/lib/server-only/public-api/get-documents.ts @@ -0,0 +1,21 @@ +import { prisma } from '@documenso/prisma'; + +type GetDocumentsProps = { + page: number; + perPage: number; +}; + +export const getDocuments = async ({ page = 1, perPage = 10 }: GetDocumentsProps) => { + const [documents, count] = await Promise.all([ + await prisma.document.findMany({ + take: perPage, + skip: Math.max(page - 1, 0) * perPage, + }), + await prisma.document.count(), + ]); + + return { + documents, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts index 194b663f7..95362c809 100644 --- a/packages/trpc/api-contract/contract.ts +++ b/packages/trpc/api-contract/contract.ts @@ -4,19 +4,19 @@ import { z } from 'zod'; const c = initContract(); const GetDocumentsQuery = z.object({ - take: z.string().default('10'), - skip: z.string().default('0'), + page: z.string().optional(), + perPage: z.string().optional(), }); const DocumentSchema = z.object({ - id: z.string(), + id: z.number(), userId: z.number(), title: z.string(), status: z.string(), documentDataId: z.string(), - createdAt: z.string(), - updatedAt: z.string(), - completedAt: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + completedAt: z.date().nullable(), }); export const contract = c.router({ diff --git a/packages/trpc/server/public-api/ts-rest.ts b/packages/trpc/server/public-api/ts-rest.ts new file mode 100644 index 000000000..0d66cda1f --- /dev/null +++ b/packages/trpc/server/public-api/ts-rest.ts @@ -0,0 +1,3 @@ +import { createNextRoute, createNextRouter } from '@ts-rest/next'; + +export { createNextRoute, createNextRouter }; From 6d6c93539f05f05d463f5ced57e7b234b78822d5 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 22 Nov 2023 15:51:04 +0200 Subject: [PATCH 003/466] feat: update contract --- packages/trpc/api-contract/contract.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts index 95362c809..976dce5fc 100644 --- a/packages/trpc/api-contract/contract.ts +++ b/packages/trpc/api-contract/contract.ts @@ -19,13 +19,18 @@ const DocumentSchema = z.object({ completedAt: z.date().nullable(), }); +const SuccessfulResponse = z.object({ + documents: DocumentSchema.array(), + totalPages: z.number(), +}); + export const contract = c.router({ getDocuments: { method: 'GET', path: '/documents', query: GetDocumentsQuery, responses: { - 200: DocumentSchema.array(), + 200: SuccessfulResponse, }, summary: 'Get all documents for a user', }, From b3008fb272349a4a40ccd56427d69f315c3dd9df Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 23 Nov 2023 10:02:22 +0200 Subject: [PATCH 004/466] feat: add route for retrieving a single document by id --- apps/web/src/pages/api/v1/[...ts-rest].tsx | 9 ++++++ packages/trpc/api-contract/contract.ts | 33 ++++++++++++++++------ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index 872d68e28..27429bdc9 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocuments } from '@documenso/lib/server-only/public-api/get-documents'; import { contract } from '@documenso/trpc/api-contract/contract'; import { createNextRoute, createNextRouter } from '@documenso/trpc/server/public-api/ts-rest'; @@ -19,6 +20,14 @@ const router = createNextRoute(contract, { }, }; }, + getDocument: async (args) => { + const document = await getDocumentById(args.params.id); + + return { + status: 200, + body: document, + }; + }, }); const nextRouter = createNextRouter(contract, router); diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts index 976dce5fc..5357e67fa 100644 --- a/packages/trpc/api-contract/contract.ts +++ b/packages/trpc/api-contract/contract.ts @@ -24,14 +24,29 @@ const SuccessfulResponse = z.object({ totalPages: z.number(), }); -export const contract = c.router({ - getDocuments: { - method: 'GET', - path: '/documents', - query: GetDocumentsQuery, - responses: { - 200: SuccessfulResponse, +export const contract = c.router( + { + getDocuments: { + method: 'GET', + path: '/documents', + query: GetDocumentsQuery, + responses: { + 200: SuccessfulResponse, + }, + summary: 'Get all documents', + }, + getDocument: { + method: 'GET', + path: `/documents/:id`, + responses: { + 200: DocumentSchema, + }, + summary: 'Get a single document', }, - summary: 'Get all documents for a user', }, -}); + { + baseHeaders: z.object({ + authorization: z.string(), + }), + }, +); From 309b56168a2da1f8c4304c7b4de7345257a04c20 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 23 Nov 2023 15:21:13 +0200 Subject: [PATCH 005/466] feat: create the model for the api token --- .../migration.sql | 21 +++++++++++++++++++ packages/prisma/schema.prisma | 18 +++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 packages/prisma/migrations/20231123132053_public_api_api_token/migration.sql diff --git a/packages/prisma/migrations/20231123132053_public_api_api_token/migration.sql b/packages/prisma/migrations/20231123132053_public_api_api_token/migration.sql new file mode 100644 index 000000000..d3c9106c4 --- /dev/null +++ b/packages/prisma/migrations/20231123132053_public_api_api_token/migration.sql @@ -0,0 +1,21 @@ +-- CreateEnum +CREATE TYPE "ApiTokenAlgorithm" AS ENUM ('SHA512'); + +-- CreateTable +CREATE TABLE "ApiToken" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "token" TEXT NOT NULL, + "algorithm" "ApiTokenAlgorithm" NOT NULL DEFAULT 'SHA512', + "expires" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" INTEGER NOT NULL, + + CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiToken_token_key" ON "ApiToken"("token"); + +-- AddForeignKey +ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_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 02807e4a0..8e073829b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -37,7 +37,8 @@ model User { Subscription Subscription? PasswordResetToken PasswordResetToken[] VerificationToken VerificationToken[] - + ApiToken ApiToken[] + @@index([email]) } @@ -60,6 +61,21 @@ model VerificationToken { user User @relation(fields: [userId], references: [id]) } +enum ApiTokenAlgorithm { + SHA512 +} + +model ApiToken { + id Int @id @default(autoincrement()) + name String + token String @unique + algorithm ApiTokenAlgorithm @default(SHA512) + expires DateTime + createdAt DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id]) +} + enum SubscriptionStatus { ACTIVE PAST_DUE From 2ccede72eaea0f70caa998404bffe36683e5fdc9 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 23 Nov 2023 15:23:47 +0200 Subject: [PATCH 006/466] chore: update the contract to add deleteDocument route --- packages/trpc/api-contract/contract.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts index 5357e67fa..2a002db45 100644 --- a/packages/trpc/api-contract/contract.ts +++ b/packages/trpc/api-contract/contract.ts @@ -3,7 +3,11 @@ import { z } from 'zod'; const c = initContract(); -const GetDocumentsQuery = z.object({ +/* + These schemas should be moved from here probably. + It grows quickly. +*/ +const GetDocumentsQuerySchema = z.object({ page: z.string().optional(), perPage: z.string().optional(), }); @@ -19,19 +23,23 @@ const DocumentSchema = z.object({ completedAt: z.date().nullable(), }); -const SuccessfulResponse = z.object({ +const SuccessfulResponseSchema = z.object({ documents: DocumentSchema.array(), totalPages: z.number(), }); +const UnsuccessfulResponseSchema = z.object({ + message: z.string(), +}); + export const contract = c.router( { getDocuments: { method: 'GET', path: '/documents', - query: GetDocumentsQuery, + query: GetDocumentsQuerySchema, responses: { - 200: SuccessfulResponse, + 200: SuccessfulResponseSchema, }, summary: 'Get all documents', }, @@ -43,6 +51,16 @@ export const contract = c.router( }, summary: 'Get a single document', }, + deleteDocument: { + method: 'DELETE', + path: `/documents/:id`, + body: z.string(), + responses: { + 200: DocumentSchema, + 404: UnsuccessfulResponseSchema, + }, + summary: 'Delete a document', + }, }, { baseHeaders: z.object({ From 80fe7ccdf5f0e66764671ec7f2d3bb22e10f506d Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Fri, 24 Nov 2023 13:59:33 +0200 Subject: [PATCH 007/466] feat: api token page in the settings --- .../app/(dashboard)/settings/token/page.tsx | 21 ++++++ .../settings/layout/desktop-nav.tsx | 17 ++++- .../settings/layout/mobile-nav.tsx | 17 ++++- .../trpc/server/api-token-router/router.ts | 65 +++++++++++++++++++ .../trpc/server/api-token-router/schema.ts | 13 ++++ packages/trpc/server/router.ts | 2 + 6 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/token/page.tsx create mode 100644 packages/trpc/server/api-token-router/router.ts create mode 100644 packages/trpc/server/api-token-router/schema.ts diff --git a/apps/web/src/app/(dashboard)/settings/token/page.tsx b/apps/web/src/app/(dashboard)/settings/token/page.tsx new file mode 100644 index 000000000..e868df47d --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/token/page.tsx @@ -0,0 +1,21 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; + +import { ApiTokenForm } from '~/components/forms/token'; + +export default async function ApiToken() { + const { user } = await getRequiredServerComponentSession(); + + return ( +
+

API Token

+ +

+ On this page, you can create new API tokens and manage the existing ones. +

+ +
+ + +
+ ); +} diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index 901c6a5ae..89bcabf60 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -1,11 +1,11 @@ 'use client'; -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { CreditCard, Key, User } from 'lucide-react'; +import { Braces, CreditCard, Key, User } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -48,6 +48,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + + + + {isBillingEnabled && ( + + + + {isBillingEnabled && ( + + + + ))} + +

Create a new token

+
+ +
+ ( + + Token Name + + + + + + )} + /> + +
+ +
+
+
+ + + ); +}; diff --git a/packages/lib/server-only/public-api/get-all-user-tokens.ts b/packages/lib/server-only/public-api/get-all-user-tokens.ts new file mode 100644 index 000000000..c6c7a7d94 --- /dev/null +++ b/packages/lib/server-only/public-api/get-all-user-tokens.ts @@ -0,0 +1,13 @@ +import { prisma } from '@documenso/prisma'; + +export type GetUserTokensOptions = { + userId: number; +}; + +export const getUserTokens = async ({ userId }: GetUserTokensOptions) => { + return prisma.apiToken.findMany({ + where: { + userId, + }, + }); +}; diff --git a/packages/trpc/server/api-token-router/router.ts b/packages/trpc/server/api-token-router/router.ts index 88f3ad9d0..49dac8809 100644 --- a/packages/trpc/server/api-token-router/router.ts +++ b/packages/trpc/server/api-token-router/router.ts @@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { deleteApiTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id'; +import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens'; import { getApiTokenById } from '@documenso/lib/server-only/public-api/get-api-token-by-id'; import { authenticatedProcedure, router } from '../trpc'; @@ -12,6 +13,16 @@ import { } from './schema'; export const apiTokenRouter = router({ + getTokens: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getUserTokens({ userId: ctx.user.id }); + } catch (e) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find your API tokens. Please try again.', + }); + } + }), getTokenById: authenticatedProcedure .input(ZGetApiTokenByIdQuerySchema) .query(async ({ input, ctx }) => { From 13997d3dca9c6e7730db54536bd184f25258b51f Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Mon, 27 Nov 2023 16:29:24 +0200 Subject: [PATCH 010/466] feat: add delete and copy token on token page --- .../app/(dashboard)/settings/token/page.tsx | 8 +- apps/web/src/components/forms/token.tsx | 93 ++++++++++++++++--- .../trpc/server/api-token-router/router.ts | 4 +- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/token/page.tsx b/apps/web/src/app/(dashboard)/settings/token/page.tsx index e868df47d..ab4992f14 100644 --- a/apps/web/src/app/(dashboard)/settings/token/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/token/page.tsx @@ -1,10 +1,6 @@ -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; - import { ApiTokenForm } from '~/components/forms/token'; -export default async function ApiToken() { - const { user } = await getRequiredServerComponentSession(); - +export default function ApiToken() { return (

API Token

@@ -15,7 +11,7 @@ export default async function ApiToken() {
- +
); } diff --git a/apps/web/src/components/forms/token.tsx b/apps/web/src/components/forms/token.tsx index 8e9d9d2b6..0daacf060 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/web/src/components/forms/token.tsx @@ -1,17 +1,27 @@ 'use client'; +import { useState } from 'react'; + import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import type { z } from 'zod'; -import type { User } from '@documenso/prisma/client'; +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; import { Form, FormControl, @@ -24,19 +34,21 @@ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type ApiTokenFormProps = { - user: User; className?: string; }; type TCreateTokenMutationSchema = z.infer; -export const ApiTokenForm = ({ user, className }: ApiTokenFormProps) => { +export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { const router = useRouter(); - + const [, copy] = useCopyToClipboard(); const { toast } = useToast(); + const [isOpen, setIsOpen] = useState(false); + const [tokenIdToDelete, setTokenIdToDelete] = useState(0); const { data: tokens } = trpc.apiToken.getTokens.useQuery(); const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation(); + const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation(); const form = useForm({ resolver: zodResolver(ZCreateTokenMutationSchema), @@ -45,12 +57,32 @@ export const ApiTokenForm = ({ user, className }: ApiTokenFormProps) => { }, }); - const deleteToken = () => { - console.log('deleted'); + const deleteToken = async (id: number) => { + try { + await deleteTokenMutation({ + id, + }); + + toast({ + title: 'Token deleted', + description: 'The token was deleted successfully.', + duration: 5000, + }); + + setIsOpen(false); + router.refresh(); + } catch (error) { + console.error(error); + } }; - const copyToken = () => { - console.log('copied'); + const copyToken = (token: string) => { + void copy(token).then(() => { + toast({ + title: 'Token copied to clipboard', + description: 'The token was copied to your clipboard.', + }); + }); }; const onSubmit = async ({ tokenName }: TCreateTokenMutationSchema) => { @@ -86,10 +118,42 @@ export const ApiTokenForm = ({ user, className }: ApiTokenFormProps) => { return (
+ + + + Are you sure you want to delete this token? + + + Please note that this action is irreversible. Once confirmed, your token will be + permanently deleted. + + + + +
+ + + +
+
+
+

Your existing tokens

    {tokens?.map((token) => ( -
  • +
  • {token.name} ({token.algorithm}) @@ -117,10 +181,17 @@ export const ApiTokenForm = ({ user, className }: ApiTokenFormProps) => { }) : 'N/A'}

    - -
    diff --git a/packages/trpc/server/api-token-router/router.ts b/packages/trpc/server/api-token-router/router.ts index 49dac8809..266c045d0 100644 --- a/packages/trpc/server/api-token-router/router.ts +++ b/packages/trpc/server/api-token-router/router.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; -import { deleteApiTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id'; +import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id'; import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens'; import { getApiTokenById } from '@documenso/lib/server-only/public-api/get-api-token-by-id'; @@ -62,7 +62,7 @@ export const apiTokenRouter = router({ try { const { id } = input; - return await deleteApiTokenById({ + return await deleteTokenById({ id, userId: ctx.user.id, }); From 6a5fc7a5fb89a7df84e0a743fe3a7a37fc551f30 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 28 Nov 2023 12:37:01 +0200 Subject: [PATCH 011/466] feat: confirm to delete dialog --- .../settings/token/delete-token-dialog.tsx | 167 ++++++++++++++++++ apps/web/src/components/forms/token.tsx | 82 ++------- 2 files changed, 177 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx diff --git a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx new file mode 100644 index 000000000..d73a3a786 --- /dev/null +++ b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DeleteTokenDialogProps = { + trigger?: React.ReactNode; + tokenId: number; + tokenName: string; +}; + +export default function DeleteTokenDialog({ trigger, tokenId, tokenName }: DeleteTokenDialogProps) { + const router = useRouter(); + const { toast } = useToast(); + const [isOpen, setIsOpen] = useState(false); + + const deleteMessage = `delete ${tokenName}`; + + const ZDeleteTokenDialogSchema = z.object({ + tokenName: z.literal(deleteMessage, { + errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }), + }), + }); + + type TDeleteTokenByIdMutationSchema = z.infer; + + const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZDeleteTokenDialogSchema), + values: { + tokenName: '', + }, + }); + + const onSubmit = async () => { + try { + await deleteTokenMutation({ + id: tokenId, + }); + + toast({ + title: 'Token deleted', + description: 'The token was deleted successfully.', + duration: 5000, + }); + + setIsOpen(false); + router.push('/settings/token'); + } catch (error) { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + duration: 5000, + description: + 'We encountered an unknown error while attempting to delete this team. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setIsOpen(value)} + > + + {trigger ?? ( + + )} + + + + Are you sure you want to delete this token? + + + Please note that this action is irreversible. Once confirmed, your token will be + permanently deleted. + + + +
    + +
    + ( + + + Confirm by typing + + {deleteMessage} + + + + + + + + )} + /> + +
    + + + +
    +
    +
    +
    + +
    +
    + ); +} diff --git a/apps/web/src/components/forms/token.tsx b/apps/web/src/components/forms/token.tsx index 0daacf060..131e90a7d 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/web/src/components/forms/token.tsx @@ -1,7 +1,5 @@ 'use client'; -import { useState } from 'react'; - import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -14,14 +12,6 @@ import { trpc } from '@documenso/trpc/react'; import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; import { Form, FormControl, @@ -33,6 +23,8 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog'; + export type ApiTokenFormProps = { className?: string; }; @@ -43,12 +35,9 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { const router = useRouter(); const [, copy] = useCopyToClipboard(); const { toast } = useToast(); - const [isOpen, setIsOpen] = useState(false); - const [tokenIdToDelete, setTokenIdToDelete] = useState(0); const { data: tokens } = trpc.apiToken.getTokens.useQuery(); const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation(); - const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation(); const form = useForm({ resolver: zodResolver(ZCreateTokenMutationSchema), @@ -57,25 +46,6 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { }, }); - const deleteToken = async (id: number) => { - try { - await deleteTokenMutation({ - id, - }); - - toast({ - title: 'Token deleted', - description: 'The token was deleted successfully.', - duration: 5000, - }); - - setIsOpen(false); - router.refresh(); - } catch (error) { - console.error(error); - } - }; - const copyToken = (token: string) => { void copy(token).then(() => { toast({ @@ -97,6 +67,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { duration: 5000, }); + form.reset(); router.refresh(); } catch (error) { if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') { @@ -109,6 +80,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { toast({ title: 'An unknown error occurred', variant: 'destructive', + duration: 5000, description: 'We encountered an unknown error while attempting create the new token. Please try again later.', }); @@ -118,35 +90,6 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { return (
    - - - - Are you sure you want to delete this token? - - - Please note that this action is irreversible. Once confirmed, your token will be - permanently deleted. - - - - -
    - - - -
    -
    -
    -

    Your existing tokens

      {tokens?.map((token) => ( @@ -181,16 +124,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { }) : 'N/A'}

      - + @@ -217,7 +151,11 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { />
      -
      From e1732de81d64a157e258f4e2350a40d61bdc9293 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 28 Nov 2023 15:49:46 +0200 Subject: [PATCH 012/466] feat: show newly created token --- .../settings/token/delete-token-dialog.tsx | 4 +- apps/web/src/components/forms/token.tsx | 112 +++++++++++------- .../public-api/get-all-user-tokens.ts | 7 ++ 3 files changed, 78 insertions(+), 45 deletions(-) diff --git a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx index d73a3a786..9a60bd60b 100644 --- a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx @@ -125,8 +125,8 @@ export default function DeleteTokenDialog({ trigger, tokenId, tokenName }: Delet render={({ field }) => ( - Confirm by typing - + Confirm by typing:{' '} + {deleteMessage} diff --git a/apps/web/src/components/forms/token.tsx b/apps/web/src/components/forms/token.tsx index 131e90a7d..a92321e6b 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/web/src/components/forms/token.tsx @@ -1,8 +1,11 @@ 'use client'; +import { useState } from 'react'; + import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import type { z } from 'zod'; @@ -35,9 +38,14 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { const router = useRouter(); const [, copy] = useCopyToClipboard(); const { toast } = useToast(); + const [newlyCreatedToken, setNewlyCreatedToken] = useState(''); - const { data: tokens } = trpc.apiToken.getTokens.useQuery(); - const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation(); + const { data: tokens, isLoading: isTokensLoading } = trpc.apiToken.getTokens.useQuery(); + const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({ + onSuccess(data) { + setNewlyCreatedToken(data.token); + }, + }); const form = useForm({ resolver: zodResolver(ZCreateTokenMutationSchema), @@ -91,48 +99,66 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { return (

      Your existing tokens

      -
        - {tokens?.map((token) => ( -
      • -
        -

        - {token.name} ({token.algorithm}) -

        -

        {token.token}

        -

        - Created:{' '} - {token.createdAt - ? new Date(token.createdAt).toLocaleDateString(undefined, { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }) - : 'N/A'} -

        -

        - Expires:{' '} - {token.expires - ? new Date(token.expires).toLocaleDateString(undefined, { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }) - : 'N/A'} -

        - - -
        -
      • - ))} -
      + {!tokens && isTokensLoading ? ( +
      + +
      + ) : ( +
        + {tokens?.map((token) => ( +
      • +
        +

        + {token.name} ({token.algorithm}) +

        + {/*

        {token.token}

        */} +

        + Created:{' '} + {token.createdAt + ? new Date(token.createdAt).toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'N/A'} +

        +

        + Expires:{' '} + {token.expires + ? new Date(token.expires).toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'N/A'} +

        + +
        +
      • + ))} +
      + )} + {newlyCreatedToken && ( +
      +

      + Your token was created successfully! Make sure to copy it because you won't be able to + see it again! +

      +

      {newlyCreatedToken}

      + +
      + )}

      Create a new token

      +

      + Enter a representative name for your new token. +

      diff --git a/packages/lib/server-only/public-api/get-all-user-tokens.ts b/packages/lib/server-only/public-api/get-all-user-tokens.ts index c6c7a7d94..d64562b83 100644 --- a/packages/lib/server-only/public-api/get-all-user-tokens.ts +++ b/packages/lib/server-only/public-api/get-all-user-tokens.ts @@ -9,5 +9,12 @@ export const getUserTokens = async ({ userId }: GetUserTokensOptions) => { where: { userId, }, + select: { + id: true, + name: true, + algorithm: true, + createdAt: true, + expires: true, + }, }); }; From d43d40fd6b1c3239b72f1b3a10d8283a8dd44b8c Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 29 Nov 2023 14:43:26 +0200 Subject: [PATCH 013/466] feat: improvements to the newly created token message --- .../settings/token/delete-token-dialog.tsx | 16 +++++++--- apps/web/src/components/forms/token.tsx | 29 ++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx index 9a60bd60b..f847cd793 100644 --- a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx @@ -1,5 +1,3 @@ -'use client'; - import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -34,9 +32,15 @@ export type DeleteTokenDialogProps = { trigger?: React.ReactNode; tokenId: number; tokenName: string; + onDelete: () => void; }; -export default function DeleteTokenDialog({ trigger, tokenId, tokenName }: DeleteTokenDialogProps) { +export default function DeleteTokenDialog({ + trigger, + tokenId, + tokenName, + onDelete, +}: DeleteTokenDialogProps) { const router = useRouter(); const { toast } = useToast(); const [isOpen, setIsOpen] = useState(false); @@ -51,7 +55,11 @@ export default function DeleteTokenDialog({ trigger, tokenId, tokenName }: Delet type TDeleteTokenByIdMutationSchema = z.infer; - const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation(); + const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({ + onSuccess() { + onDelete(); + }, + }); const form = useForm({ resolver: zodResolver(ZDeleteTokenDialogSchema), diff --git a/apps/web/src/components/forms/token.tsx b/apps/web/src/components/forms/token.tsx index a92321e6b..85d6cc038 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/web/src/components/forms/token.tsx @@ -38,12 +38,13 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { const router = useRouter(); const [, copy] = useCopyToClipboard(); const { toast } = useToast(); - const [newlyCreatedToken, setNewlyCreatedToken] = useState(''); + const [newlyCreatedToken, setNewlyCreatedToken] = useState({ id: 0, token: '' }); + const [showNewToken, setShowNewToken] = useState(false); const { data: tokens, isLoading: isTokensLoading } = trpc.apiToken.getTokens.useQuery(); const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({ onSuccess(data) { - setNewlyCreatedToken(data.token); + setNewlyCreatedToken({ id: data.id, token: data.token }); }, }); @@ -54,6 +55,12 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { }, }); + const onDelete = (tokenId: number) => { + if (tokenId === newlyCreatedToken.id) { + setShowNewToken((prev) => !prev); + } + }; + const copyToken = (token: string) => { void copy(token).then(() => { toast({ @@ -75,6 +82,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { duration: 5000, }); + setShowNewToken(true); form.reset(); router.refresh(); } catch (error) { @@ -114,7 +122,6 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {

      {token.name} ({token.algorithm})

      - {/*

      {token.token}

      */}

      Created:{' '} {token.createdAt @@ -137,20 +144,28 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { }) : 'N/A'}

      - + onDelete(token.id)} + />
      ))}
    )} - {newlyCreatedToken && ( + {newlyCreatedToken.token && showNewToken && (

    Your token was created successfully! Make sure to copy it because you won't be able to see it again!

    -

    {newlyCreatedToken}

    -
    From 76800674ee7314fd5e3c39ba3ff515184c754999 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 29 Nov 2023 14:57:27 +0200 Subject: [PATCH 014/466] feat: improve messaging --- apps/web/src/components/forms/token.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/web/src/components/forms/token.tsx b/apps/web/src/components/forms/token.tsx index 85d6cc038..954d4dd2e 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/web/src/components/forms/token.tsx @@ -55,6 +55,11 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { }, }); + /* + This method is called in "delete-token-dialog.tsx" after a successful mutation + to avoid deleting the snippet with the newly created token from the screen + when users delete any of their tokens except the newly created one. + */ const onDelete = (tokenId: number) => { if (tokenId === newlyCreatedToken.id) { setShowNewToken((prev) => !prev); @@ -107,6 +112,15 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { return (

    Your existing tokens

    + {tokens?.length === 0 ? ( +
    +

    + Your tokens will be shown here once you create them. +

    +
    + ) : ( +
    + )} {!tokens && isTokensLoading ? (
    From 6be4b7ae904b9ad657852f233b58a62a03c30f4b Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 30 Nov 2023 14:39:31 +0200 Subject: [PATCH 015/466] feat: add authorization for api calls --- apps/web/src/pages/api/v1/[...ts-rest].tsx | 88 +++++++++++++++++-- .../server-only/public-api/get-documents.ts | 6 +- .../public-api/get-user-by-token.ts | 15 ++++ packages/trpc/api-contract/contract.ts | 5 ++ 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 packages/lib/server-only/public-api/get-user-by-token.ts diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index 27429bdc9..ee2e5b934 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -1,16 +1,38 @@ import type { NextApiRequest, NextApiResponse } from 'next'; +import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocuments } from '@documenso/lib/server-only/public-api/get-documents'; +import { checkUserFromToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; import { contract } from '@documenso/trpc/api-contract/contract'; import { createNextRoute, createNextRouter } from '@documenso/trpc/server/public-api/ts-rest'; +const validateUserToken = async (token: string) => { + try { + return await checkUserFromToken({ token }); + } catch (e) { + return null; + } +}; + const router = createNextRoute(contract, { getDocuments: async (args) => { const page = Number(args.query.page) || 1; const perPage = Number(args.query.perPage) || 10; + const { authorization } = args.headers; - const { documents, totalPages } = await getDocuments({ page, perPage }); + const user = await validateUserToken(authorization); + + if (!user) { + return { + status: 401, + body: { + message: 'Unauthorized', + }, + }; + } + + const { documents, totalPages } = await getDocuments({ page, perPage, userId: user.id }); return { status: 200, @@ -21,12 +43,66 @@ const router = createNextRoute(contract, { }; }, getDocument: async (args) => { - const document = await getDocumentById(args.params.id); + const { id: documentId } = args.params; + const { authorization } = args.headers; - return { - status: 200, - body: document, - }; + const user = await validateUserToken(authorization); + + if (!user) { + return { + status: 401, + body: { + message: 'Unauthorized', + }, + }; + } + + try { + const document = await getDocumentById({ id: Number(documentId), userId: user.id }); + + return { + status: 200, + body: document, + }; + } catch (e) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + }, + deleteDocument: async (args) => { + const { id: documentId } = args.params; + const { authorization } = args.headers; + + const user = await validateUserToken(authorization); + + if (!user) { + return { + status: 401, + body: { + message: 'Unauthorized', + }, + }; + } + + try { + const document = await deleteDraftDocument({ id: Number(documentId), userId: user.id }); + + return { + status: 200, + body: document, + }; + } catch (e) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } }, }); diff --git a/packages/lib/server-only/public-api/get-documents.ts b/packages/lib/server-only/public-api/get-documents.ts index bbc3ab14c..deea612e8 100644 --- a/packages/lib/server-only/public-api/get-documents.ts +++ b/packages/lib/server-only/public-api/get-documents.ts @@ -3,11 +3,15 @@ import { prisma } from '@documenso/prisma'; type GetDocumentsProps = { page: number; perPage: number; + userId: number; }; -export const getDocuments = async ({ page = 1, perPage = 10 }: GetDocumentsProps) => { +export const getDocuments = async ({ page = 1, perPage = 10, userId }: GetDocumentsProps) => { const [documents, count] = await Promise.all([ await prisma.document.findMany({ + where: { + userId, + }, take: perPage, skip: Math.max(page - 1, 0) * perPage, }), diff --git a/packages/lib/server-only/public-api/get-user-by-token.ts b/packages/lib/server-only/public-api/get-user-by-token.ts new file mode 100644 index 000000000..3092deaa7 --- /dev/null +++ b/packages/lib/server-only/public-api/get-user-by-token.ts @@ -0,0 +1,15 @@ +import { prisma } from '@documenso/prisma'; + +export const checkUserFromToken = async ({ token }: { token: string }) => { + const user = await prisma.user.findFirstOrThrow({ + where: { + ApiToken: { + some: { + token: token, + }, + }, + }, + }); + + return user; +}; diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts index 2a002db45..1a15e5fd0 100644 --- a/packages/trpc/api-contract/contract.ts +++ b/packages/trpc/api-contract/contract.ts @@ -40,6 +40,8 @@ export const contract = c.router( query: GetDocumentsQuerySchema, responses: { 200: SuccessfulResponseSchema, + 401: UnsuccessfulResponseSchema, + 404: UnsuccessfulResponseSchema, }, summary: 'Get all documents', }, @@ -48,6 +50,8 @@ export const contract = c.router( path: `/documents/:id`, responses: { 200: DocumentSchema, + 401: UnsuccessfulResponseSchema, + 404: UnsuccessfulResponseSchema, }, summary: 'Get a single document', }, @@ -57,6 +61,7 @@ export const contract = c.router( body: z.string(), responses: { 200: DocumentSchema, + 401: UnsuccessfulResponseSchema, 404: UnsuccessfulResponseSchema, }, summary: 'Delete a document', From 6c5526dd49a4777483a0101b8fe0e13368c64254 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 6 Dec 2023 15:27:30 +0000 Subject: [PATCH 016/466] chore: update routes trying to add the route for creating documents --- apps/web/src/pages/api/v1/[...ts-rest].tsx | 73 +++++++++++++++++++++- package-lock.json | 50 +++++++++++++++ package.json | 1 - packages/trpc/api-contract/contract.ts | 24 +++++++ packages/trpc/package.json | 1 + 5 files changed, 145 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index ee2e5b934..1c915da30 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -1,9 +1,12 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document'; +import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; +import { createDocument } from '@documenso/lib/server-only/document/create-document'; +import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocuments } from '@documenso/lib/server-only/public-api/get-documents'; import { checkUserFromToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; import { contract } from '@documenso/trpc/api-contract/contract'; import { createNextRoute, createNextRouter } from '@documenso/trpc/server/public-api/ts-rest'; @@ -89,11 +92,17 @@ const router = createNextRoute(contract, { } try { - const document = await deleteDraftDocument({ id: Number(documentId), userId: user.id }); + const document = await getDocumentById({ id: Number(documentId), userId: user.id }); + + const deletedDocument = await deleteDocument({ + id: Number(documentId), + userId: user.id, + status: document.status, + }); return { status: 200, - body: document, + body: deletedDocument, }; } catch (e) { return { @@ -104,6 +113,64 @@ const router = createNextRoute(contract, { }; } }, + createDocument: async (args) => { + const { authorization } = args.headers; + const { body } = args; + + const user = await validateUserToken(authorization); + + if (!user) { + return { + status: 401, + body: { + message: 'Unauthorized', + }, + }; + } + + try { + const regexPattern = /filename="(.+?)"/; + const match = body.toString().match(regexPattern); + const documentTitle = match?.[1] ?? 'Untitled document'; + + console.log(body.toString()); + + const file = new Blob([body.toString()], { + type: 'application/pdf', + }); + + const { type, data } = await putFile(file); + + const { id: documentDataId } = await createDocumentData({ + type, + data, + }); + + const { id } = await createDocument({ + title: documentTitle, + documentDataId, + userId: user.id, + }); + + return { + status: 200, + body: { + uploadedFile: { + id, + message: 'Document uploaded successfuly', + }, + }, + }; + } catch (e) { + console.error(e); + return { + status: 500, + body: { + message: 'An error occurred while uploading your document.', + }, + }; + } + }, }); const nextRouter = createNextRouter(contract, router); diff --git a/package-lock.json b/package-lock.json index 72bca2771..f54eb40ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -167,6 +167,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anatine/zod-openapi": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/@anatine/zod-openapi/-/zod-openapi-1.14.2.tgz", + "integrity": "sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==", + "dependencies": { + "ts-deepmerge": "^6.0.3" + }, + "peerDependencies": { + "openapi3-ts": "^2.0.0 || ^3.0.0", + "zod": "^3.20.0" + } + }, "node_modules/@aws-crypto/crc32": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", @@ -5989,6 +6001,19 @@ } } }, + "node_modules/@ts-rest/open-api": { + "version": "3.30.5", + "resolved": "https://registry.npmjs.org/@ts-rest/open-api/-/open-api-3.30.5.tgz", + "integrity": "sha512-FOq6afvj6VCLMSQEO8J0B2YuZ2BfvQrscMy9i5rinI4sJO2/q17fdUqOoT9AI6n4coHCOFpcRUOz2xks7Nn5fA==", + "dependencies": { + "@anatine/zod-openapi": "^1.12.0", + "openapi3-ts": "^2.0.2" + }, + "peerDependencies": { + "@ts-rest/core": "3.30.5", + "zod": "^3.22.3" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -14367,6 +14392,22 @@ "node": ">= 14.17.0" } }, + "node_modules/openapi3-ts": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.2.tgz", + "integrity": "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==", + "dependencies": { + "yaml": "^1.10.2" + } + }, + "node_modules/openapi3-ts/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/openid-client": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", @@ -17830,6 +17871,14 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-deepmerge": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz", + "integrity": "sha512-2qxI/FZVDPbzh63GwWIZYE7daWKtwXZYuyc8YNq0iTmMUwn4mL0jRLsp6hfFlgbdRSR4x2ppe+E86FnvEpN7Nw==", + "engines": { + "node": ">=14.13.1" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -19490,6 +19539,7 @@ "@trpc/server": "^10.36.0", "@ts-rest/core": "^3.30.5", "@ts-rest/next": "^3.30.5", + "@ts-rest/open-api": "^3.30.5", "luxon": "^3.4.0", "superjson": "^1.13.1", "ts-pattern": "^5.0.5", diff --git a/package.json b/package.json index 2e708363f..3f1806988 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "apps/*", "packages/*" ], - "dependencies": {}, "overrides": { "next-auth": { "next": "14.0.3" diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts index 1a15e5fd0..3dd5142cb 100644 --- a/packages/trpc/api-contract/contract.ts +++ b/packages/trpc/api-contract/contract.ts @@ -23,6 +23,18 @@ const DocumentSchema = z.object({ completedAt: z.date().nullable(), }); +const SendDocumentForSigningMutationSchema = z.object({ + signerEmail: z.string(), + signerName: z.string().optional(), +}); + +const UploadDocumentSuccessfulSchema = z.object({ + uploadedFile: z.object({ + id: z.number(), + message: z.string(), + }), +}); + const SuccessfulResponseSchema = z.object({ documents: DocumentSchema.array(), totalPages: z.number(), @@ -66,6 +78,18 @@ export const contract = c.router( }, summary: 'Delete a document', }, + createDocument: { + method: 'POST', + path: '/documents', + contentType: 'multipart/form-data', + body: c.type<{ file: File }>(), + responses: { + 200: UploadDocumentSuccessfulSchema, + 401: UnsuccessfulResponseSchema, + 500: UnsuccessfulResponseSchema, + }, + summary: 'Upload a new document', + }, }, { baseHeaders: z.object({ diff --git a/packages/trpc/package.json b/packages/trpc/package.json index fb32bcdf3..69aba9116 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -20,6 +20,7 @@ "@ts-rest/core": "^3.30.5", "@ts-rest/next": "^3.30.5", "luxon": "^3.4.0", + "@ts-rest/open-api": "^3.30.5", "superjson": "^1.13.1", "ts-pattern": "^5.0.5", "zod": "^3.22.4" From 11ae6d3c16a2d7f728c929e26fb9a2eee69b144a Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 6 Dec 2023 16:53:34 +0000 Subject: [PATCH 017/466] chore: small changes --- apps/web/src/pages/api/v1/[...ts-rest].tsx | 4 +--- package-lock.json | 14 -------------- packages/trpc/package.json | 1 - 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index 1c915da30..b8e36340b 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -133,9 +133,7 @@ const router = createNextRoute(contract, { const match = body.toString().match(regexPattern); const documentTitle = match?.[1] ?? 'Untitled document'; - console.log(body.toString()); - - const file = new Blob([body.toString()], { + const file = new Blob([body], { type: 'application/pdf', }); diff --git a/package-lock.json b/package-lock.json index f54eb40ca..d244df9e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6001,19 +6001,6 @@ } } }, - "node_modules/@ts-rest/open-api": { - "version": "3.30.5", - "resolved": "https://registry.npmjs.org/@ts-rest/open-api/-/open-api-3.30.5.tgz", - "integrity": "sha512-FOq6afvj6VCLMSQEO8J0B2YuZ2BfvQrscMy9i5rinI4sJO2/q17fdUqOoT9AI6n4coHCOFpcRUOz2xks7Nn5fA==", - "dependencies": { - "@anatine/zod-openapi": "^1.12.0", - "openapi3-ts": "^2.0.2" - }, - "peerDependencies": { - "@ts-rest/core": "3.30.5", - "zod": "^3.22.3" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -19539,7 +19526,6 @@ "@trpc/server": "^10.36.0", "@ts-rest/core": "^3.30.5", "@ts-rest/next": "^3.30.5", - "@ts-rest/open-api": "^3.30.5", "luxon": "^3.4.0", "superjson": "^1.13.1", "ts-pattern": "^5.0.5", diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 69aba9116..fb32bcdf3 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -20,7 +20,6 @@ "@ts-rest/core": "^3.30.5", "@ts-rest/next": "^3.30.5", "luxon": "^3.4.0", - "@ts-rest/open-api": "^3.30.5", "superjson": "^1.13.1", "ts-pattern": "^5.0.5", "zod": "^3.22.4" From 54401b94ae8e9c12be1e92d674541ae3953f90d5 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Fri, 8 Dec 2023 09:58:23 +0000 Subject: [PATCH 018/466] chore: split api contract moved the schemas from the api contract to a separate file --- packages/trpc/api-contract/contract.ts | 100 ++++++++++--------------- packages/trpc/api-contract/schema.ts | 67 +++++++++++++++++ 2 files changed, 107 insertions(+), 60 deletions(-) create mode 100644 packages/trpc/api-contract/schema.ts diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts index 3dd5142cb..8e8f7b9bd 100644 --- a/packages/trpc/api-contract/contract.ts +++ b/packages/trpc/api-contract/contract.ts @@ -1,49 +1,20 @@ import { initContract } from '@ts-rest/core'; -import { z } from 'zod'; + +import { + AuthorizationHeadersSchema, + CreateDocumentMutationSchema, + DeleteDocumentMutationSchema, + GetDocumentsQuerySchema, + SendDocumentForSigningMutationSchema, + SuccessfulDocumentResponseSchema, + SuccessfulResponseSchema, + SuccessfulSigningResponseSchema, + UnsuccessfulResponseSchema, + UploadDocumentSuccessfulSchema, +} from './schema'; const c = initContract(); -/* - These schemas should be moved from here probably. - It grows quickly. -*/ -const GetDocumentsQuerySchema = z.object({ - page: z.string().optional(), - perPage: z.string().optional(), -}); - -const DocumentSchema = z.object({ - id: z.number(), - userId: z.number(), - title: z.string(), - status: z.string(), - documentDataId: z.string(), - createdAt: z.date(), - updatedAt: z.date(), - completedAt: z.date().nullable(), -}); - -const SendDocumentForSigningMutationSchema = z.object({ - signerEmail: z.string(), - signerName: z.string().optional(), -}); - -const UploadDocumentSuccessfulSchema = z.object({ - uploadedFile: z.object({ - id: z.number(), - message: z.string(), - }), -}); - -const SuccessfulResponseSchema = z.object({ - documents: DocumentSchema.array(), - totalPages: z.number(), -}); - -const UnsuccessfulResponseSchema = z.object({ - message: z.string(), -}); - export const contract = c.router( { getDocuments: { @@ -61,39 +32,48 @@ export const contract = c.router( method: 'GET', path: `/documents/:id`, responses: { - 200: DocumentSchema, + 200: SuccessfulDocumentResponseSchema, 401: UnsuccessfulResponseSchema, 404: UnsuccessfulResponseSchema, }, summary: 'Get a single document', }, + createDocument: { + method: 'POST', + path: '/documents', + body: CreateDocumentMutationSchema, + responses: { + 200: UploadDocumentSuccessfulSchema, + 401: UnsuccessfulResponseSchema, + 404: UnsuccessfulResponseSchema, + }, + summary: 'Upload a new document and get a presigned URL', + }, + sendDocumentForSigning: { + method: 'PATCH', + path: '/documents/:id/send-for-signing', + body: SendDocumentForSigningMutationSchema, + responses: { + 200: SuccessfulSigningResponseSchema, + 400: UnsuccessfulResponseSchema, + 401: UnsuccessfulResponseSchema, + 404: UnsuccessfulResponseSchema, + }, + summary: 'Send a document for signing', + }, deleteDocument: { method: 'DELETE', path: `/documents/:id`, - body: z.string(), + body: DeleteDocumentMutationSchema, responses: { - 200: DocumentSchema, + 200: SuccessfulDocumentResponseSchema, 401: UnsuccessfulResponseSchema, 404: UnsuccessfulResponseSchema, }, summary: 'Delete a document', }, - createDocument: { - method: 'POST', - path: '/documents', - contentType: 'multipart/form-data', - body: c.type<{ file: File }>(), - responses: { - 200: UploadDocumentSuccessfulSchema, - 401: UnsuccessfulResponseSchema, - 500: UnsuccessfulResponseSchema, - }, - summary: 'Upload a new document', - }, }, { - baseHeaders: z.object({ - authorization: z.string(), - }), + baseHeaders: AuthorizationHeadersSchema, }, ); diff --git a/packages/trpc/api-contract/schema.ts b/packages/trpc/api-contract/schema.ts new file mode 100644 index 000000000..504fa55b2 --- /dev/null +++ b/packages/trpc/api-contract/schema.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const GetDocumentsQuerySchema = z.object({ + page: z.string().optional(), + perPage: z.string().optional(), +}); + +export const DeleteDocumentMutationSchema = z.string(); + +export const SuccessfulDocumentResponseSchema = z.object({ + id: z.number(), + userId: z.number(), + title: z.string(), + status: z.string(), + documentDataId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + completedAt: z.date().nullable(), +}); + +export const SendDocumentForSigningMutationSchema = z.object({ + signerEmail: z.string(), + signerName: z.string().optional(), + emailSubject: z.string().optional(), + emailBody: z.string().optional(), + fields: z.array( + z.object({ + fieldType: z.nativeEnum(FieldType), + pageNumber: z.number(), + pageX: z.number(), + pageY: z.number(), + pageWidth: z.number(), + pageHeight: z.number(), + }), + ), +}); + +export const UploadDocumentSuccessfulSchema = z.object({ + uploadedFile: z.object({ + url: z.string(), + key: z.string(), + }), +}); + +export const CreateDocumentMutationSchema = z.object({ + fileName: z.string(), + contentType: z.string().default('PDF'), +}); + +export const SuccessfulResponseSchema = z.object({ + documents: SuccessfulDocumentResponseSchema.array(), + totalPages: z.number(), +}); + +export const SuccessfulSigningResponseSchema = z.object({ + message: z.string(), +}); + +export const UnsuccessfulResponseSchema = z.object({ + message: z.string(), +}); + +export const AuthorizationHeadersSchema = z.object({ + authorization: z.string(), +}); From 66c0db91da6ffe3e2709e3f72f2d32823a61e5c5 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Fri, 8 Dec 2023 13:28:34 +0000 Subject: [PATCH 019/466] chore: cleanup and feedback implementation --- .../settings/layout/desktop-nav.tsx | 2 +- .../settings/layout/mobile-nav.tsx | 2 +- apps/web/src/pages/api/v1/[...ts-rest].tsx | 106 ++++++++++++++---- .../public-api/delete-api-token-by-id.ts | 2 +- .../server-only/public-api/get-documents.ts | 25 ----- packages/trpc/api-contract/schema.ts | 6 +- .../trpc/server/api-token-router/router.ts | 3 + 7 files changed, 90 insertions(+), 56 deletions(-) delete mode 100644 packages/lib/server-only/public-api/get-documents.ts diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index 214c5af99..848ff17cc 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -57,7 +57,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { )} > - API Token + API Tokens diff --git a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx index ce61ba97d..7482c2f10 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx @@ -60,7 +60,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => { )} > - API Token + API Tokens diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index b8e36340b..96e1584a1 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -1,12 +1,14 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; -import { createDocument } from '@documenso/lib/server-only/document/create-document'; +import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; +import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; -import { getDocuments } from '@documenso/lib/server-only/public-api/get-documents'; +import { sendDocument } from '@documenso/lib/server-only/document/send-document'; +import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { checkUserFromToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { contract } from '@documenso/trpc/api-contract/contract'; import { createNextRoute, createNextRouter } from '@documenso/trpc/server/public-api/ts-rest'; @@ -35,7 +37,7 @@ const router = createNextRoute(contract, { }; } - const { documents, totalPages } = await getDocuments({ page, perPage, userId: user.id }); + const { data: documents, totalPages } = await findDocuments({ page, perPage, userId: user.id }); return { status: 200, @@ -114,7 +116,30 @@ const router = createNextRoute(contract, { } }, createDocument: async (args) => { + const { body } = args; + + try { + const { url, key } = await getPresignPostUrl(body.fileName, body.contentType); + + return { + status: 200, + body: { + url, + key, + }, + }; + } catch (e) { + return { + status: 404, + body: { + message: 'An error has occured while uploading the file', + }, + }; + } + }, + sendDocumentForSigning: async (args) => { const { authorization } = args.headers; + const { id } = args.params; const { body } = args; const user = await validateUserToken(authorization); @@ -128,39 +153,72 @@ const router = createNextRoute(contract, { }; } + const document = await getDocumentById({ id: Number(id), userId: user.id }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === 'PENDING') { + return { + status: 400, + body: { + message: 'Document is already waiting for signing', + }, + }; + } + try { - const regexPattern = /filename="(.+?)"/; - const match = body.toString().match(regexPattern); - const documentTitle = match?.[1] ?? 'Untitled document'; - - const file = new Blob([body], { - type: 'application/pdf', + await setRecipientsForDocument({ + userId: user.id, + documentId: Number(id), + recipients: [ + { + email: body.signerEmail, + name: body.signerName ?? '', + }, + ], }); - const { type, data } = await putFile(file); - - const { id: documentDataId } = await createDocumentData({ - type, - data, + await setFieldsForDocument({ + documentId: Number(id), + userId: user.id, + fields: body.fields.map((field) => ({ + signerEmail: body.signerEmail, + type: field.fieldType, + pageNumber: field.pageNumber, + pageX: field.pageX, + pageY: field.pageY, + pageWidth: field.pageWidth, + pageHeight: field.pageHeight, + })), }); - const { id } = await createDocument({ - title: documentTitle, - documentDataId, + if (body.emailBody || body.emailSubject) { + await upsertDocumentMeta({ + documentId: Number(id), + subject: body.emailSubject ?? '', + message: body.emailBody ?? '', + }); + } + + await sendDocument({ + documentId: Number(id), userId: user.id, }); return { status: 200, body: { - uploadedFile: { - id, - message: 'Document uploaded successfuly', - }, + message: 'Document sent for signing successfully', }, }; } catch (e) { - console.error(e); return { status: 500, body: { diff --git a/packages/lib/server-only/public-api/delete-api-token-by-id.ts b/packages/lib/server-only/public-api/delete-api-token-by-id.ts index af176063f..398288006 100644 --- a/packages/lib/server-only/public-api/delete-api-token-by-id.ts +++ b/packages/lib/server-only/public-api/delete-api-token-by-id.ts @@ -6,7 +6,7 @@ export type DeleteTokenByIdOptions = { }; export const deleteTokenById = async ({ id, userId }: DeleteTokenByIdOptions) => { - return prisma.apiToken.delete({ + return await prisma.apiToken.delete({ where: { id, userId, diff --git a/packages/lib/server-only/public-api/get-documents.ts b/packages/lib/server-only/public-api/get-documents.ts deleted file mode 100644 index deea612e8..000000000 --- a/packages/lib/server-only/public-api/get-documents.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -type GetDocumentsProps = { - page: number; - perPage: number; - userId: number; -}; - -export const getDocuments = async ({ page = 1, perPage = 10, userId }: GetDocumentsProps) => { - const [documents, count] = await Promise.all([ - await prisma.document.findMany({ - where: { - userId, - }, - take: perPage, - skip: Math.max(page - 1, 0) * perPage, - }), - await prisma.document.count(), - ]); - - return { - documents, - totalPages: Math.ceil(count / perPage), - }; -}; diff --git a/packages/trpc/api-contract/schema.ts b/packages/trpc/api-contract/schema.ts index 504fa55b2..d62d50d52 100644 --- a/packages/trpc/api-contract/schema.ts +++ b/packages/trpc/api-contract/schema.ts @@ -38,10 +38,8 @@ export const SendDocumentForSigningMutationSchema = z.object({ }); export const UploadDocumentSuccessfulSchema = z.object({ - uploadedFile: z.object({ - url: z.string(), - key: z.string(), - }), + url: z.string(), + key: z.string(), }); export const CreateDocumentMutationSchema = z.object({ diff --git a/packages/trpc/server/api-token-router/router.ts b/packages/trpc/server/api-token-router/router.ts index 266c045d0..bae094456 100644 --- a/packages/trpc/server/api-token-router/router.ts +++ b/packages/trpc/server/api-token-router/router.ts @@ -23,6 +23,7 @@ export const apiTokenRouter = router({ }); } }), + getTokenById: authenticatedProcedure .input(ZGetApiTokenByIdQuerySchema) .query(async ({ input, ctx }) => { @@ -40,6 +41,7 @@ export const apiTokenRouter = router({ }); } }), + createToken: authenticatedProcedure .input(ZCreateTokenMutationSchema) .mutation(async ({ input, ctx }) => { @@ -56,6 +58,7 @@ export const apiTokenRouter = router({ }); } }), + deleteTokenById: authenticatedProcedure .input(ZDeleteTokenByIdMutationSchema) .mutation(async ({ input, ctx }) => { From 8ecd8a7d10ea9635d21cd4aac7cf94bb8281af5d Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Mon, 11 Dec 2023 14:33:30 +0200 Subject: [PATCH 020/466] chore: implemented feedback + a small refactoring --- .../app/(dashboard)/settings/token/page.tsx | 2 +- apps/web/src/components/forms/token.tsx | 15 ++----- apps/web/src/pages/api/v1/[...ts-rest].tsx | 43 +++++++++---------- packages/lib/constants/time.ts | 2 + .../public-api/create-api-token.ts | 4 +- .../public-api/get-user-by-token.ts | 15 ++++++- 6 files changed, 42 insertions(+), 39 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/token/page.tsx b/apps/web/src/app/(dashboard)/settings/token/page.tsx index ab4992f14..889e7a2a8 100644 --- a/apps/web/src/app/(dashboard)/settings/token/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/token/page.tsx @@ -3,7 +3,7 @@ import { ApiTokenForm } from '~/components/forms/token'; export default function ApiToken() { return (
    -

    API Token

    +

    API Tokens

    On this page, you can create new API tokens and manage the existing ones. diff --git a/apps/web/src/components/forms/token.tsx b/apps/web/src/components/forms/token.tsx index 954d4dd2e..f3f05028e 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/web/src/components/forms/token.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { Loader } from 'lucide-react'; +import { DateTime } from 'luxon'; import { useForm } from 'react-hook-form'; import type { z } from 'zod'; @@ -139,23 +140,13 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {

    Created:{' '} {token.createdAt - ? new Date(token.createdAt).toLocaleDateString(undefined, { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }) + ? DateTime.fromJSDate(token.createdAt).toLocaleString(DateTime.DATETIME_FULL) : 'N/A'}

    Expires:{' '} {token.expires - ? new Date(token.expires).toLocaleDateString(undefined, { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }) + ? DateTime.fromJSDate(token.createdAt).toLocaleString(DateTime.DATETIME_FULL) : 'N/A'}

    { - try { - return await checkUserFromToken({ token }); - } catch (e) { - return null; - } -}; - const router = createNextRoute(contract, { getDocuments: async (args) => { const page = Number(args.query.page) || 1; const perPage = Number(args.query.perPage) || 10; const { authorization } = args.headers; + let user; - const user = await validateUserToken(authorization); - - if (!user) { + try { + user = await checkUserFromToken({ token: authorization }); + } catch (e) { return { status: 401, body: { - message: 'Unauthorized', + message: e.message, }, }; } @@ -50,14 +43,15 @@ const router = createNextRoute(contract, { getDocument: async (args) => { const { id: documentId } = args.params; const { authorization } = args.headers; + let user; - const user = await validateUserToken(authorization); - - if (!user) { + try { + user = await checkUserFromToken({ token: authorization }); + } catch (e) { return { status: 401, body: { - message: 'Unauthorized', + message: e.message, }, }; } @@ -82,13 +76,15 @@ const router = createNextRoute(contract, { const { id: documentId } = args.params; const { authorization } = args.headers; - const user = await validateUserToken(authorization); + let user; - if (!user) { + try { + user = await checkUserFromToken({ token: authorization }); + } catch (e) { return { status: 401, body: { - message: 'Unauthorized', + message: e.message, }, }; } @@ -141,14 +137,15 @@ const router = createNextRoute(contract, { const { authorization } = args.headers; const { id } = args.params; const { body } = args; + let user; - const user = await validateUserToken(authorization); - - if (!user) { + try { + user = await checkUserFromToken({ token: authorization }); + } catch (e) { return { status: 401, body: { - message: 'Unauthorized', + message: e.message, }, }; } diff --git a/packages/lib/constants/time.ts b/packages/lib/constants/time.ts index e2581e14c..c1a262048 100644 --- a/packages/lib/constants/time.ts +++ b/packages/lib/constants/time.ts @@ -3,3 +3,5 @@ export const ONE_MINUTE = ONE_SECOND * 60; export const ONE_HOUR = ONE_MINUTE * 60; export const ONE_DAY = ONE_HOUR * 24; export const ONE_WEEK = ONE_DAY * 7; +export const ONE_MONTH = ONE_DAY * 30; +export const ONE_YEAR = ONE_DAY * 365; diff --git a/packages/lib/server-only/public-api/create-api-token.ts b/packages/lib/server-only/public-api/create-api-token.ts index 582053359..645e9fb1b 100644 --- a/packages/lib/server-only/public-api/create-api-token.ts +++ b/packages/lib/server-only/public-api/create-api-token.ts @@ -3,7 +3,7 @@ import crypto from 'crypto'; import { prisma } from '@documenso/prisma'; // temporary choice for testing only -import { ONE_WEEK } from '../../constants/time'; +import { ONE_YEAR } from '../../constants/time'; type CreateApiTokenInput = { userId: number; @@ -22,7 +22,7 @@ export const createApiToken = async ({ userId, tokenName }: CreateApiTokenInput) token: tokenHash, name: tokenName, userId, - expires: new Date(Date.now() + ONE_WEEK), + expires: new Date(Date.now() + ONE_YEAR), }, }); diff --git a/packages/lib/server-only/public-api/get-user-by-token.ts b/packages/lib/server-only/public-api/get-user-by-token.ts index 3092deaa7..277fc13b2 100644 --- a/packages/lib/server-only/public-api/get-user-by-token.ts +++ b/packages/lib/server-only/public-api/get-user-by-token.ts @@ -1,7 +1,7 @@ import { prisma } from '@documenso/prisma'; export const checkUserFromToken = async ({ token }: { token: string }) => { - const user = await prisma.user.findFirstOrThrow({ + const user = await prisma.user.findFirst({ where: { ApiToken: { some: { @@ -9,7 +9,20 @@ export const checkUserFromToken = async ({ token }: { token: string }) => { }, }, }, + include: { + ApiToken: true, + }, }); + if (!user) { + throw new Error('Token not found'); + } + + const tokenObject = user.ApiToken.find((apiToken) => apiToken.token === token); + + if (!tokenObject || new Date(tokenObject.expires) < new Date()) { + throw new Error('The API token has expired'); + } + return user; }; From 19736ce60b082dc317684352e484839924ed4163 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 14 Dec 2023 11:05:39 +0200 Subject: [PATCH 021/466] chore: implemented feedback --- apps/web/src/components/forms/token.tsx | 24 ++++++++++++++----- .../public-api/get-user-by-token.ts | 4 ++-- packages/trpc/server/field-router/router.ts | 2 ++ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/forms/token.tsx b/apps/web/src/components/forms/token.tsx index f3f05028e..56b91b467 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/web/src/components/forms/token.tsx @@ -63,17 +63,29 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { */ const onDelete = (tokenId: number) => { if (tokenId === newlyCreatedToken.id) { - setShowNewToken((prev) => !prev); + setShowNewToken(false); } }; - const copyToken = (token: string) => { - void copy(token).then(() => { + const copyToken = async (token: string) => { + try { + const copied = await copy(token); + + if (!copied) { + throw new Error('Unable to copy the token'); + } + toast({ title: 'Token copied to clipboard', description: 'The token was copied to your clipboard.', }); - }); + } catch (error) { + toast({ + title: 'Unable to copy token', + description: 'We were unable to copy the token to your clipboard. Please try again.', + variant: 'destructive', + }); + } }; const onSubmit = async ({ tokenName }: TCreateTokenMutationSchema) => { @@ -146,7 +158,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {

    Expires:{' '} {token.expires - ? DateTime.fromJSDate(token.createdAt).toLocaleString(DateTime.DATETIME_FULL) + ? DateTime.fromJSDate(token.expires).toLocaleString(DateTime.DATETIME_FULL) : 'N/A'}

    { diff --git a/packages/lib/server-only/public-api/get-user-by-token.ts b/packages/lib/server-only/public-api/get-user-by-token.ts index 277fc13b2..5e696521c 100644 --- a/packages/lib/server-only/public-api/get-user-by-token.ts +++ b/packages/lib/server-only/public-api/get-user-by-token.ts @@ -15,13 +15,13 @@ export const checkUserFromToken = async ({ token }: { token: string }) => { }); if (!user) { - throw new Error('Token not found'); + throw new Error('Invalid token'); } const tokenObject = user.ApiToken.find((apiToken) => apiToken.token === token); if (!tokenObject || new Date(tokenObject.expires) < new Date()) { - throw new Error('The API token has expired'); + throw new Error('Expired token'); } return user; diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 7d049df0d..1dbc89426 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -18,6 +18,8 @@ export const fieldRouter = router({ try { const { documentId, fields } = input; + console.log('fields', fields); + return await setFieldsForDocument({ documentId, userId: ctx.user.id, From da03fc1fd0be29ede4130fb67a64219da9e5d54c Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Mon, 18 Dec 2023 12:24:42 +0200 Subject: [PATCH 022/466] chore: finishing touches --- .../(dashboard)/settings/token/delete-token-dialog.tsx | 4 ++-- apps/web/src/components/forms/token.tsx | 3 +++ packages/trpc/server/field-router/router.ts | 2 -- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx index f847cd793..1e3513d98 100644 --- a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx @@ -88,7 +88,7 @@ export default function DeleteTokenDialog({ variant: 'destructive', duration: 5000, description: - 'We encountered an unknown error while attempting to delete this team. Please try again later.', + 'We encountered an unknown error while attempting to delete this token. Please try again later.', }); } }; @@ -151,7 +151,7 @@ export default function DeleteTokenDialog({ type="button" variant="secondary" className="flex-1" - onClick={(prev) => setIsOpen(!prev)} + onClick={() => setIsOpen(false)} > Cancel diff --git a/apps/web/src/components/forms/token.tsx b/apps/web/src/components/forms/token.tsx index 56b91b467..9091b7501 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/web/src/components/forms/token.tsx @@ -134,6 +134,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { ) : (
    )} + {!tokens && isTokensLoading ? (
    @@ -171,6 +172,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { ))}
)} + {newlyCreatedToken.token && showNewToken && (

@@ -187,6 +189,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {

)} +

Create a new token

Enter a representative name for your new token. diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 1dbc89426..7d049df0d 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -18,8 +18,6 @@ export const fieldRouter = router({ try { const { documentId, fields } = input; - console.log('fields', fields); - return await setFieldsForDocument({ documentId, userId: ctx.user.id, From 17486b961d99bcfbf8908ef1f732d0ee0811b385 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 19 Dec 2023 15:51:43 +0200 Subject: [PATCH 023/466] chore: refactor delete dialog --- .../settings/token/delete-token-dialog.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx index 1e3513d98..b3f57018b 100644 --- a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx @@ -44,6 +44,7 @@ export default function DeleteTokenDialog({ const router = useRouter(); const { toast } = useToast(); const [isOpen, setIsOpen] = useState(false); + const [isDeleteEnabled, setIsDeleteEnabled] = useState(false); const deleteMessage = `delete ${tokenName}`; @@ -68,6 +69,10 @@ export default function DeleteTokenDialog({ }, }); + const onInputChange = (event: React.ChangeEvent) => { + setIsDeleteEnabled(event.target.value === deleteMessage); + }; + const onSubmit = async () => { try { await deleteTokenMutation({ @@ -94,10 +99,11 @@ export default function DeleteTokenDialog({ }; useEffect(() => { - if (!open) { + if (!isOpen) { + setIsDeleteEnabled(false); form.reset(); } - }, [open, form]); + }, [isOpen, form]); return (

- + { + onInputChange(value); + field.onChange(value); + }} + /> @@ -159,7 +173,7 @@ export default function DeleteTokenDialog({
diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index c8adb1422..a0cc4b8e8 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -12,7 +12,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; -import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { StackAvatarsWithUI } from '~/components/(dashboard)/avatar/stack-avatars-with-ui'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -64,9 +64,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { { header: 'Recipient', accessorKey: 'recipient', - cell: ({ row }) => { - return ; - }, + cell: ({ row }) => , }, { header: 'Status', diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx new file mode 100644 index 000000000..d7f3106e6 --- /dev/null +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-component.tsx @@ -0,0 +1,71 @@ +import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import type { Recipient } from '@documenso/prisma/client'; + +import { AvatarWithRecipient } from './avatar-with-recipient'; +import { StackAvatar } from './stack-avatar'; + +export const StackAvatarsComponent = ({ recipients }: { recipients: Recipient[] }) => { + const waitingRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'waiting', + ); + + const openedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'opened', + ); + + const completedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'completed', + ); + + const uncompletedRecipients = recipients.filter( + (recipient) => getRecipientType(recipient) === 'unsigned', + ); + return ( +
+ {completedRecipients.length > 0 && ( +
+

Completed

+ {completedRecipients.map((recipient: Recipient) => ( +
+ + {recipient.email} +
+ ))} +
+ )} + + {waitingRecipients.length > 0 && ( +
+

Waiting

+ {waitingRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} + + {openedRecipients.length > 0 && ( +
+

Opened

+ {openedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} + + {uncompletedRecipients.length > 0 && ( +
+

Uncompleted

+ {uncompletedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx deleted file mode 100644 index 7429d8ee5..000000000 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; -import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Recipient } from '@documenso/prisma/client'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@documenso/ui/primitives/tooltip'; - -import { AvatarWithRecipient } from './avatar-with-recipient'; -import { StackAvatar } from './stack-avatar'; -import { StackAvatars } from './stack-avatars'; - -export type StackAvatarsWithTooltipProps = { - recipients: Recipient[]; - position?: 'top' | 'bottom'; - children?: React.ReactNode; -}; - -export const StackAvatarsWithTooltip = ({ - recipients, - position, - children, -}: StackAvatarsWithTooltipProps) => { - const waitingRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'waiting', - ); - - const openedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'opened', - ); - - const completedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'completed', - ); - - const uncompletedRecipients = recipients.filter( - (recipient) => getRecipientType(recipient) === 'unsigned', - ); - - return ( - - - - {children || } - - - -
- {completedRecipients.length > 0 && ( -
-

Completed

- {completedRecipients.map((recipient: Recipient) => ( -
- - {recipient.email} -
- ))} -
- )} - - {waitingRecipients.length > 0 && ( -
-

Waiting

- {waitingRecipients.map((recipient: Recipient) => ( - - ))} -
- )} - - {openedRecipients.length > 0 && ( -
-

Opened

- {openedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} - - {uncompletedRecipients.length > 0 && ( -
-

Uncompleted

- {uncompletedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} -
-
-
-
- ); -}; diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx new file mode 100644 index 000000000..5d5f24413 --- /dev/null +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size'; +import type { Recipient } from '@documenso/prisma/client'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@documenso/ui/primitives/tooltip'; + +import { StackAvatars } from './stack-avatars'; +import { StackAvatarsComponent } from './stack-avatars-component'; + +export type StackAvatarsWithUIProps = { + recipients: Recipient[]; + position?: 'top' | 'bottom'; + children?: React.ReactNode; +}; + +export const StackAvatarsWithUI = ({ recipients, position, children }: StackAvatarsWithUIProps) => { + const size = useWindowSize(); + return ( + <> + {size.width > 1050 ? ( + + + + {children || } + + + + + + + + ) : ( + + + {children || } + + + + + + + )} + + ); +}; From fb46b09e4f6ceb90e9a76f63bfc748c041736c4b Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 20 Dec 2023 12:47:46 +0200 Subject: [PATCH 025/466] chore: small changes --- apps/web/src/pages/api/v1/[...ts-rest].tsx | 8 ++++---- packages/trpc/api-contract/contract.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index 18a0b3001..1c645a3ea 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -67,7 +67,7 @@ const router = createNextRoute(contract, { return { status: 404, body: { - message: 'Document not found', + message: e.message ?? 'Document not found', }, }; } @@ -106,7 +106,7 @@ const router = createNextRoute(contract, { return { status: 404, body: { - message: 'Document not found', + message: e.message ?? 'Document not found', }, }; } @@ -128,7 +128,7 @@ const router = createNextRoute(contract, { return { status: 404, body: { - message: 'An error has occured while uploading the file', + message: e.message ?? 'An error has occured while uploading the file', }, }; } @@ -219,7 +219,7 @@ const router = createNextRoute(contract, { return { status: 500, body: { - message: 'An error occurred while uploading your document.', + message: e.message ?? 'An error has occured while sending the document for signing', }, }; } diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts index 8e8f7b9bd..5f28a0c63 100644 --- a/packages/trpc/api-contract/contract.ts +++ b/packages/trpc/api-contract/contract.ts @@ -51,13 +51,14 @@ export const contract = c.router( }, sendDocumentForSigning: { method: 'PATCH', - path: '/documents/:id/send-for-signing', + path: '/documents/:id/send', body: SendDocumentForSigningMutationSchema, responses: { 200: SuccessfulSigningResponseSchema, 400: UnsuccessfulResponseSchema, 401: UnsuccessfulResponseSchema, 404: UnsuccessfulResponseSchema, + 500: UnsuccessfulResponseSchema, }, summary: 'Send a document for signing', }, From a22ada5f415a907ded9bfba1dc4eed324eec5665 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 20 Dec 2023 14:44:43 +0200 Subject: [PATCH 026/466] chore: add delete cascade --- .../migration.sql | 5 +++++ packages/prisma/schema.prisma | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/prisma/migrations/20231220124343_add_cascade_delete_user_apitoken/migration.sql diff --git a/packages/prisma/migrations/20231220124343_add_cascade_delete_user_apitoken/migration.sql b/packages/prisma/migrations/20231220124343_add_cascade_delete_user_apitoken/migration.sql new file mode 100644 index 000000000..4eb0b4760 --- /dev/null +++ b/packages/prisma/migrations/20231220124343_add_cascade_delete_user_apitoken/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "ApiToken" DROP CONSTRAINT "ApiToken_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3f8e82b37..77609645e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -76,7 +76,7 @@ model ApiToken { expires DateTime createdAt DateTime @default(now()) userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } enum SubscriptionStatus { From d283cc2d26033e2231f6ebc5d2d820f49ecf92c9 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 21 Dec 2023 16:02:02 +0200 Subject: [PATCH 027/466] chore: implemented feedback --- apps/web/src/pages/api/v1/[...ts-rest].tsx | 18 +++++---------- packages/lib/server-only/auth/hash.ts | 5 ++++ .../public-api/create-api-token.ts | 23 ++++++++++--------- .../public-api/get-user-by-token.ts | 10 +++++--- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index 1c645a3ea..0b22d97c6 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -1,12 +1,10 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; -import { checkUserFromToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; +import { getUserByApiToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { contract } from '@documenso/trpc/api-contract/contract'; @@ -20,7 +18,7 @@ const router = createNextRoute(contract, { let user; try { - user = await checkUserFromToken({ token: authorization }); + user = await getUserByApiToken({ token: authorization }); } catch (e) { return { status: 401, @@ -46,7 +44,7 @@ const router = createNextRoute(contract, { let user; try { - user = await checkUserFromToken({ token: authorization }); + user = await getUserByApiToken({ token: authorization }); } catch (e) { return { status: 401, @@ -79,7 +77,7 @@ const router = createNextRoute(contract, { let user; try { - user = await checkUserFromToken({ token: authorization }); + user = await getUserByApiToken({ token: authorization }); } catch (e) { return { status: 401, @@ -140,7 +138,7 @@ const router = createNextRoute(contract, { let user; try { - user = await checkUserFromToken({ token: authorization }); + user = await getUserByApiToken({ token: authorization }); } catch (e) { return { status: 401, @@ -226,8 +224,4 @@ const router = createNextRoute(contract, { }, }); -const nextRouter = createNextRouter(contract, router); - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - await nextRouter(req, res); -} +export default createNextRouter(contract, router); diff --git a/packages/lib/server-only/auth/hash.ts b/packages/lib/server-only/auth/hash.ts index df9931c97..bb0b760fe 100644 --- a/packages/lib/server-only/auth/hash.ts +++ b/packages/lib/server-only/auth/hash.ts @@ -1,4 +1,5 @@ import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt'; +import crypto from 'crypto'; import { SALT_ROUNDS } from '../../constants/auth'; @@ -12,3 +13,7 @@ export const hashSync = (password: string) => { export const compareSync = (password: string, hash: string) => { return bcryptCompareSync(password, hash); }; + +export const hashString = (input: string) => { + return crypto.createHash('sha512').update(input).digest('hex'); +}; diff --git a/packages/lib/server-only/public-api/create-api-token.ts b/packages/lib/server-only/public-api/create-api-token.ts index 645e9fb1b..9fe74c1f6 100644 --- a/packages/lib/server-only/public-api/create-api-token.ts +++ b/packages/lib/server-only/public-api/create-api-token.ts @@ -1,9 +1,9 @@ -import crypto from 'crypto'; - import { prisma } from '@documenso/prisma'; // temporary choice for testing only import { ONE_YEAR } from '../../constants/time'; +import { alphaid } from '../../universal/id'; +import { hashString } from '../auth/hash'; type CreateApiTokenInput = { userId: number; @@ -11,24 +11,25 @@ type CreateApiTokenInput = { }; export const createApiToken = async ({ userId, tokenName }: CreateApiTokenInput) => { - // quick implementation for testing; it needs double checking - const tokenHash = crypto - .createHash('sha512') - .update(crypto.randomBytes(32).toString('hex')) - .digest('hex'); + const apiToken = `api_${alphaid(16)}`; - const token = await prisma.apiToken.create({ + const hashedToken = hashString(apiToken); + + const dbToken = await prisma.apiToken.create({ data: { - token: tokenHash, + token: hashedToken, name: tokenName, userId, expires: new Date(Date.now() + ONE_YEAR), }, }); - if (!token) { + if (!dbToken) { throw new Error(`Failed to create the API token`); } - return token; + return { + id: dbToken.id, + token: apiToken, + }; }; diff --git a/packages/lib/server-only/public-api/get-user-by-token.ts b/packages/lib/server-only/public-api/get-user-by-token.ts index 5e696521c..3377b5aa8 100644 --- a/packages/lib/server-only/public-api/get-user-by-token.ts +++ b/packages/lib/server-only/public-api/get-user-by-token.ts @@ -1,11 +1,15 @@ import { prisma } from '@documenso/prisma'; -export const checkUserFromToken = async ({ token }: { token: string }) => { +import { hashString } from '../auth/hash'; + +export const getUserByApiToken = async ({ token }: { token: string }) => { + const hashedToken = hashString(token); + const user = await prisma.user.findFirst({ where: { ApiToken: { some: { - token: token, + token: hashedToken, }, }, }, @@ -18,7 +22,7 @@ export const checkUserFromToken = async ({ token }: { token: string }) => { throw new Error('Invalid token'); } - const tokenObject = user.ApiToken.find((apiToken) => apiToken.token === token); + const tokenObject = user.ApiToken.find((apiToken) => apiToken.token === hashedToken); if (!tokenObject || new Date(tokenObject.expires) < new Date()) { throw new Error('Expired token'); From ce67de9a1cb1ddb5db8092814c08de8f644fe65d Mon Sep 17 00:00:00 2001 From: Ashraf Date: Fri, 29 Dec 2023 19:29:13 +0800 Subject: [PATCH 028/466] refactor: changed component name for better readability --- apps/web/src/app/(dashboard)/documents/[id]/page.tsx | 6 +++--- apps/web/src/app/(dashboard)/documents/data-table.tsx | 4 ++-- .../{stack-avatars-with-ui.tsx => stack-avatars-ui.tsx} | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) rename apps/web/src/components/(dashboard)/avatar/{stack-avatars-with-ui.tsx => stack-avatars-ui.tsx} (91%) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index ce18f27b8..708746af1 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -11,7 +11,7 @@ import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/clie import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; -import { StackAvatarsWithUI } from '~/components/(dashboard)/avatar/stack-avatars-with-ui'; +import { StackAvatarsUI } from '~/components/(dashboard)/avatar/stack-avatars-ui'; import { DocumentStatus } from '~/components/formatter/document-status'; export type DocumentPageProps = { @@ -71,9 +71,9 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
- + {recipients.length} Recipient(s) - +
)} diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index a0cc4b8e8..ca2da02d3 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -12,7 +12,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; -import { StackAvatarsWithUI } from '~/components/(dashboard)/avatar/stack-avatars-with-ui'; +import { StackAvatarsUI } from '~/components/(dashboard)/avatar/stack-avatars-ui'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -64,7 +64,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { { header: 'Recipient', accessorKey: 'recipient', - cell: ({ row }) => , + cell: ({ row }) => , }, { header: 'Status', diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx similarity index 91% rename from apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx rename to apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx index 5d5f24413..c1c44836a 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-ui.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-ui.tsx @@ -13,14 +13,15 @@ import { import { StackAvatars } from './stack-avatars'; import { StackAvatarsComponent } from './stack-avatars-component'; -export type StackAvatarsWithUIProps = { +export type StackAvatarsUIProps = { recipients: Recipient[]; position?: 'top' | 'bottom'; children?: React.ReactNode; }; -export const StackAvatarsWithUI = ({ recipients, position, children }: StackAvatarsWithUIProps) => { +export const StackAvatarsUI = ({ recipients, position, children }: StackAvatarsUIProps) => { const size = useWindowSize(); + return ( <> {size.width > 1050 ? ( From a1215df91a186a36b2acdbbb463f09a1c8304daf Mon Sep 17 00:00:00 2001 From: Mythie Date: Sun, 31 Dec 2023 13:58:15 +1100 Subject: [PATCH 029/466] refactor: extract api implementation to package Extracts the API implementation to a package so we can potentially reuse it across different applications in the event that we move off using a Next.js API route. Additionally tidies up the tokens page and form to be more simplified. --- .vscode/settings.json | 2 +- apps/web/package.json | 1 + .../app/(dashboard)/settings/token/page.tsx | 55 +++- .../settings/token/delete-token-dialog.tsx | 52 ++-- apps/web/src/components/forms/token.tsx | 157 ++++------ apps/web/src/pages/api/v1/[...ts-rest].tsx | 230 +-------------- package-lock.json | 268 +++++++++++++++--- packages/api/index.ts | 1 + packages/api/next.ts | 1 + packages/api/package.json | 28 ++ packages/api/tsconfig.json | 8 + packages/api/v1/contract.ts | 84 ++++++ packages/api/v1/implementation.ts | 178 ++++++++++++ packages/api/v1/middleware/authenticated.ts | 37 +++ packages/api/v1/schema.ts | 87 ++++++ .../public-api/get-all-user-tokens.ts | 5 +- .../trpc/server/api-token-router/schema.ts | 6 + 17 files changed, 802 insertions(+), 398 deletions(-) create mode 100644 packages/api/index.ts create mode 100644 packages/api/next.ts create mode 100644 packages/api/package.json create mode 100644 packages/api/tsconfig.json create mode 100644 packages/api/v1/contract.ts create mode 100644 packages/api/v1/implementation.ts create mode 100644 packages/api/v1/middleware/authenticated.ts create mode 100644 packages/api/v1/schema.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 97d5d1948..82aa3c1a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "javascript.preferences.importModuleSpecifier": "non-relative", diff --git a/apps/web/package.json b/apps/web/package.json index 150982c2d..bc32298b2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" }, "dependencies": { + "@documenso/api": "*", "@documenso/assets": "*", "@documenso/ee": "*", "@documenso/lib": "*", diff --git a/apps/web/src/app/(dashboard)/settings/token/page.tsx b/apps/web/src/app/(dashboard)/settings/token/page.tsx index 889e7a2a8..86143c633 100644 --- a/apps/web/src/app/(dashboard)/settings/token/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/token/page.tsx @@ -1,9 +1,21 @@ +import { DateTime } from 'luxon'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens'; +import { Button } from '@documenso/ui/primitives/button'; + +import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog'; +import { LocaleDate } from '~/components/formatter/locale-date'; import { ApiTokenForm } from '~/components/forms/token'; -export default function ApiToken() { +export default async function ApiTokensPage() { + const { user } = await getRequiredServerComponentSession(); + + const tokens = await getUserTokens({ userId: user.id }); + return (
-

API Tokens

+

API Tokens

On this page, you can create new API tokens and manage the existing ones. @@ -12,6 +24,45 @@ export default function ApiToken() {


+ +
+ +

Your existing tokens

+ + {tokens.length === 0 && ( +
+

+ Your tokens will be shown here once you create them. +

+
+ )} + + {tokens.length > 0 && ( +
+ {tokens.map((token) => ( +
+
+
+
{token.name}
+ +

+ Created on +

+

+ Expires on +

+
+ +
+ + + +
+
+
+ ))} +
+ )}
); } diff --git a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx index b3f57018b..ba0a4cc99 100644 --- a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -6,6 +8,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import type { ApiToken } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -29,24 +32,18 @@ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type DeleteTokenDialogProps = { - trigger?: React.ReactNode; - tokenId: number; - tokenName: string; - onDelete: () => void; + token: Pick; + onDelete?: () => void; + children?: React.ReactNode; }; -export default function DeleteTokenDialog({ - trigger, - tokenId, - tokenName, - onDelete, -}: DeleteTokenDialogProps) { +export default function DeleteTokenDialog({ token, onDelete, children }: DeleteTokenDialogProps) { const router = useRouter(); const { toast } = useToast(); - const [isOpen, setIsOpen] = useState(false); - const [isDeleteEnabled, setIsDeleteEnabled] = useState(false); - const deleteMessage = `delete ${tokenName}`; + const [isOpen, setIsOpen] = useState(false); + + const deleteMessage = `delete ${token.name}`; const ZDeleteTokenDialogSchema = z.object({ tokenName: z.literal(deleteMessage, { @@ -58,7 +55,7 @@ export default function DeleteTokenDialog({ const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({ onSuccess() { - onDelete(); + onDelete?.(); }, }); @@ -69,14 +66,10 @@ export default function DeleteTokenDialog({ }, }); - const onInputChange = (event: React.ChangeEvent) => { - setIsDeleteEnabled(event.target.value === deleteMessage); - }; - const onSubmit = async () => { try { await deleteTokenMutation({ - id: tokenId, + id: token.id, }); toast({ @@ -86,7 +79,8 @@ export default function DeleteTokenDialog({ }); setIsOpen(false); - router.push('/settings/token'); + + router.refresh(); } catch (error) { toast({ title: 'An unknown error occurred', @@ -100,7 +94,6 @@ export default function DeleteTokenDialog({ useEffect(() => { if (!isOpen) { - setIsDeleteEnabled(false); form.reset(); } }, [isOpen, form]); @@ -111,12 +104,13 @@ export default function DeleteTokenDialog({ onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)} > - {trigger ?? ( + {children ?? ( )} + Are you sure you want to delete this token? @@ -144,21 +138,15 @@ export default function DeleteTokenDialog({ {deleteMessage} + - { - onInputChange(value); - field.onChange(value); - }} - /> + )} /> +
-
- )} - -

Create a new token

-

- Enter a representative name for your new token. -

-
+
( - + Token Name - - - + +
+ + + + + +
+ + + Please enter a meaningful name for your token. This will help you identify it + later. + +
)} /> -
+
+ + {newlyCreatedToken && ( + + +

+ Your token was created successfully! Make sure to copy it because you won't be able to + see it again! +

+ +

+ {newlyCreatedToken} +

+ + +
+
+ )} ); }; diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index 0b22d97c6..15b618ebd 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -1,227 +1,5 @@ -import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; -import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; -import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; -import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; -import { sendDocument } from '@documenso/lib/server-only/document/send-document'; -import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; -import { getUserByApiToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; -import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; -import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; -import { contract } from '@documenso/trpc/api-contract/contract'; -import { createNextRoute, createNextRouter } from '@documenso/trpc/server/public-api/ts-rest'; +import { createNextRouter } from '@documenso/api/next'; +import { ApiContractV1 } from '@documenso/api/v1/contract'; +import { ApiContractV1Implementation } from '@documenso/api/v1/implementation'; -const router = createNextRoute(contract, { - getDocuments: async (args) => { - const page = Number(args.query.page) || 1; - const perPage = Number(args.query.perPage) || 10; - const { authorization } = args.headers; - let user; - - try { - user = await getUserByApiToken({ token: authorization }); - } catch (e) { - return { - status: 401, - body: { - message: e.message, - }, - }; - } - - const { data: documents, totalPages } = await findDocuments({ page, perPage, userId: user.id }); - - return { - status: 200, - body: { - documents, - totalPages, - }, - }; - }, - getDocument: async (args) => { - const { id: documentId } = args.params; - const { authorization } = args.headers; - let user; - - try { - user = await getUserByApiToken({ token: authorization }); - } catch (e) { - return { - status: 401, - body: { - message: e.message, - }, - }; - } - - try { - const document = await getDocumentById({ id: Number(documentId), userId: user.id }); - - return { - status: 200, - body: document, - }; - } catch (e) { - return { - status: 404, - body: { - message: e.message ?? 'Document not found', - }, - }; - } - }, - deleteDocument: async (args) => { - const { id: documentId } = args.params; - const { authorization } = args.headers; - - let user; - - try { - user = await getUserByApiToken({ token: authorization }); - } catch (e) { - return { - status: 401, - body: { - message: e.message, - }, - }; - } - - try { - const document = await getDocumentById({ id: Number(documentId), userId: user.id }); - - const deletedDocument = await deleteDocument({ - id: Number(documentId), - userId: user.id, - status: document.status, - }); - - return { - status: 200, - body: deletedDocument, - }; - } catch (e) { - return { - status: 404, - body: { - message: e.message ?? 'Document not found', - }, - }; - } - }, - createDocument: async (args) => { - const { body } = args; - - try { - const { url, key } = await getPresignPostUrl(body.fileName, body.contentType); - - return { - status: 200, - body: { - url, - key, - }, - }; - } catch (e) { - return { - status: 404, - body: { - message: e.message ?? 'An error has occured while uploading the file', - }, - }; - } - }, - sendDocumentForSigning: async (args) => { - const { authorization } = args.headers; - const { id } = args.params; - const { body } = args; - let user; - - try { - user = await getUserByApiToken({ token: authorization }); - } catch (e) { - return { - status: 401, - body: { - message: e.message, - }, - }; - } - - const document = await getDocumentById({ id: Number(id), userId: user.id }); - - if (!document) { - return { - status: 404, - body: { - message: 'Document not found', - }, - }; - } - - if (document.status === 'PENDING') { - return { - status: 400, - body: { - message: 'Document is already waiting for signing', - }, - }; - } - - try { - await setRecipientsForDocument({ - userId: user.id, - documentId: Number(id), - recipients: [ - { - email: body.signerEmail, - name: body.signerName ?? '', - }, - ], - }); - - await setFieldsForDocument({ - documentId: Number(id), - userId: user.id, - fields: body.fields.map((field) => ({ - signerEmail: body.signerEmail, - type: field.fieldType, - pageNumber: field.pageNumber, - pageX: field.pageX, - pageY: field.pageY, - pageWidth: field.pageWidth, - pageHeight: field.pageHeight, - })), - }); - - if (body.emailBody || body.emailSubject) { - await upsertDocumentMeta({ - documentId: Number(id), - subject: body.emailSubject ?? '', - message: body.emailBody ?? '', - }); - } - - await sendDocument({ - documentId: Number(id), - userId: user.id, - }); - - return { - status: 200, - body: { - message: 'Document sent for signing successfully', - }, - }; - } catch (e) { - return { - status: 500, - body: { - message: e.message ?? 'An error has occured while sending the document for signing', - }, - }; - } - }, -}); - -export default createNextRouter(contract, router); +export default createNextRouter(ApiContractV1, ApiContractV1Implementation); diff --git a/package-lock.json b/package-lock.json index d244df9e8..148b02096 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "version": "1.2.3", "license": "AGPL-3.0", "dependencies": { + "@documenso/api": "*", "@documenso/assets": "*", "@documenso/ee": "*", "@documenso/lib": "*", @@ -167,18 +168,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@anatine/zod-openapi": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@anatine/zod-openapi/-/zod-openapi-1.14.2.tgz", - "integrity": "sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==", - "dependencies": { - "ts-deepmerge": "^6.0.3" - }, - "peerDependencies": { - "openapi3-ts": "^2.0.0 || ^3.0.0", - "zod": "^3.20.0" - } - }, "node_modules/@aws-crypto/crc32": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", @@ -1776,6 +1765,10 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@documenso/api": { + "resolved": "packages/api", + "link": true + }, "node_modules/@documenso/app-tests": { "resolved": "packages/app-tests", "link": true @@ -14379,22 +14372,6 @@ "node": ">= 14.17.0" } }, - "node_modules/openapi3-ts": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.2.tgz", - "integrity": "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==", - "dependencies": { - "yaml": "^1.10.2" - } - }, - "node_modules/openapi3-ts/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } - }, "node_modules/openid-client": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", @@ -17858,14 +17835,6 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-deepmerge": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz", - "integrity": "sha512-2qxI/FZVDPbzh63GwWIZYE7daWKtwXZYuyc8YNq0iTmMUwn4mL0jRLsp6hfFlgbdRSR4x2ppe+E86FnvEpN7Nw==", - "engines": { - "node": ">=14.13.1" - } - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -19268,6 +19237,233 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/api": { + "name": "@documenso/api", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@documenso/lib": "*", + "@documenso/prisma": "*", + "@ts-rest/core": "^3.30.5", + "@ts-rest/next": "^3.30.5", + "luxon": "^3.4.0", + "superjson": "^1.13.1", + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" + }, + "devDependencies": {} + }, + "packages/api/node_modules/@next/env": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.6.tgz", + "integrity": "sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw==", + "peer": true + }, + "packages/api/node_modules/@next/swc-darwin-arm64": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.6.tgz", + "integrity": "sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-darwin-x64": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.6.tgz", + "integrity": "sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-linux-arm64-gnu": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.6.tgz", + "integrity": "sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-linux-arm64-musl": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.6.tgz", + "integrity": "sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-linux-x64-gnu": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.6.tgz", + "integrity": "sha512-Mc2b4xiIWKXIhBy2NBTwOxGD3nHLmq4keFk+d4/WL5fMsB8XdJRdtUlL87SqVCTSaf1BRuQQf1HvXZcy+rq3Nw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-linux-x64-musl": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.6.tgz", + "integrity": "sha512-CFHvP9Qz98NruJiUnCe61O6GveKKHpJLloXbDSWRhqhkJdZD2zU5hG+gtVJR//tyW897izuHpM6Gtf6+sNgJPQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-win32-arm64-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.6.tgz", + "integrity": "sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-win32-ia32-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.6.tgz", + "integrity": "sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-win32-x64-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.6.tgz", + "integrity": "sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@ts-rest/next": { + "version": "3.30.5", + "resolved": "https://registry.npmjs.org/@ts-rest/next/-/next-3.30.5.tgz", + "integrity": "sha512-NasfUN7SnwcjJNbxvvcemC4fOv4f4IF5I14wVqQODN0HWPokkrta6XLuv0eKQJYdB32AS7VINQhls8Sj1AIN0g==", + "peerDependencies": { + "@ts-rest/core": "3.30.5", + "next": "^12.0.0 || ^13.0.0", + "zod": "^3.22.3" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "packages/api/node_modules/next": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/next/-/next-13.5.6.tgz", + "integrity": "sha512-Y2wTcTbO4WwEsVb4A8VSnOsG1I9ok+h74q0ZdxkwM3EODqrs4pasq7O0iUxbcS9VtWMicG7f3+HAj0r1+NtKSw==", + "peer": true, + "dependencies": { + "@next/env": "13.5.6", + "@swc/helpers": "0.5.2", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001406", + "postcss": "8.4.31", + "styled-jsx": "5.1.1", + "watchpack": "2.4.0" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=16.14.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "13.5.6", + "@next/swc-darwin-x64": "13.5.6", + "@next/swc-linux-arm64-gnu": "13.5.6", + "@next/swc-linux-arm64-musl": "13.5.6", + "@next/swc-linux-x64-gnu": "13.5.6", + "@next/swc-linux-x64-musl": "13.5.6", + "@next/swc-win32-arm64-msvc": "13.5.6", + "@next/swc-win32-ia32-msvc": "13.5.6", + "@next/swc-win32-x64-msvc": "13.5.6" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, "packages/app-tests": { "name": "@documenso/app-tests", "version": "1.0.0", diff --git a/packages/api/index.ts b/packages/api/index.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/api/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/api/next.ts b/packages/api/next.ts new file mode 100644 index 000000000..5ac5aab45 --- /dev/null +++ b/packages/api/next.ts @@ -0,0 +1 @@ +export { createNextRouter } from '@ts-rest/next'; diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 000000000..9aea9b26f --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,28 @@ +{ + "name": "@documenso/api", + "version": "1.0.0", + "main": "./index.ts", + "types": "./index.ts", + "license": "MIT", + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "clean": "rimraf node_modules" + }, + "files": [ + "index.ts", + "next.ts", + "v1/" + ], + "dependencies": { + "@documenso/lib": "*", + "@documenso/prisma": "*", + "@ts-rest/core": "^3.30.5", + "@ts-rest/next": "^3.30.5", + "luxon": "^3.4.0", + "superjson": "^1.13.1", + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" + }, + "devDependencies": {} +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 000000000..dc21318a7 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@documenso/tsconfig/react-library.json", + "include": ["."], + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "strict": true, + } +} diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts new file mode 100644 index 000000000..0f853a020 --- /dev/null +++ b/packages/api/v1/contract.ts @@ -0,0 +1,84 @@ +import { initContract } from '@ts-rest/core'; + +import { + ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema, + ZAuthorizationHeadersSchema, + ZCreateDocumentMutationSchema, + ZDeleteDocumentMutationSchema, + ZGetDocumentsQuerySchema, + ZSuccessfulDocumentResponseSchema, + ZSuccessfulResponseSchema, + ZSuccessfulSigningResponseSchema, + ZUnsuccessfulResponseSchema, + ZUploadDocumentSuccessfulSchema, +} from './schema'; + +const c = initContract(); + +export const ApiContractV1 = c.router( + { + getDocuments: { + method: 'GET', + path: '/documents', + query: ZGetDocumentsQuerySchema, + responses: { + 200: ZSuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Get all documents', + }, + + getDocument: { + method: 'GET', + path: `/documents/:id`, + responses: { + 200: ZSuccessfulDocumentResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Get a single document', + }, + + createDocument: { + method: 'POST', + path: '/documents', + body: ZCreateDocumentMutationSchema, + responses: { + 200: ZUploadDocumentSuccessfulSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Upload a new document and get a presigned URL', + }, + + sendDocument: { + method: 'PATCH', + path: '/documents/:id/send', + body: SendDocumentMutationSchema, + responses: { + 200: ZSuccessfulSigningResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Send a document for signing', + }, + + deleteDocument: { + method: 'DELETE', + path: `/documents/:id`, + body: ZDeleteDocumentMutationSchema, + responses: { + 200: ZSuccessfulDocumentResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Delete a document', + }, + }, + { + baseHeaders: ZAuthorizationHeadersSchema, + }, +); diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts new file mode 100644 index 000000000..b317e95d6 --- /dev/null +++ b/packages/api/v1/implementation.ts @@ -0,0 +1,178 @@ +import { createNextRoute } from '@ts-rest/next'; + +import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; +import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; +import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { sendDocument } from '@documenso/lib/server-only/document/send-document'; +import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; +import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; + +import { ApiContractV1 } from './contract'; +import { authenticatedMiddleware } from './middleware/authenticated'; + +export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { + getDocuments: authenticatedMiddleware(async (args, user) => { + const page = Number(args.query.page) || 1; + const perPage = Number(args.query.perPage) || 10; + + const { data: documents, totalPages } = await findDocuments({ page, perPage, userId: user.id }); + + return { + status: 200, + body: { + documents, + totalPages, + }, + }; + }), + + getDocument: authenticatedMiddleware(async (args, user) => { + const { id: documentId } = args.params; + + try { + const document = await getDocumentById({ id: Number(documentId), userId: user.id }); + + return { + status: 200, + body: document, + }; + } catch (err) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + }), + + deleteDocument: authenticatedMiddleware(async (args, user) => { + const { id: documentId } = args.params; + + try { + const document = await getDocumentById({ id: Number(documentId), userId: user.id }); + + const deletedDocument = await deleteDocument({ + id: Number(documentId), + userId: user.id, + status: document.status, + }); + + return { + status: 200, + body: deletedDocument, + }; + } catch (err) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + }), + + createDocument: authenticatedMiddleware(async (args, _user) => { + const { body } = args; + + try { + const { url, key } = await getPresignPostUrl(body.fileName, body.contentType); + + return { + status: 200, + body: { + url, + key, + }, + }; + } catch (err) { + return { + status: 404, + body: { + message: 'An error has occured while uploading the file', + }, + }; + } + }), + + sendDocument: authenticatedMiddleware(async (args, user) => { + const { id } = args.params; + const { body } = args; + + const document = await getDocumentById({ id: Number(id), userId: user.id }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === 'PENDING') { + return { + status: 400, + body: { + message: 'Document is already waiting for signing', + }, + }; + } + + try { + await setRecipientsForDocument({ + userId: user.id, + documentId: Number(id), + recipients: [ + { + email: body.signerEmail, + name: body.signerName ?? '', + }, + ], + }); + + await setFieldsForDocument({ + documentId: Number(id), + userId: user.id, + fields: body.fields.map((field) => ({ + signerEmail: body.signerEmail, + type: field.fieldType, + pageNumber: field.pageNumber, + pageX: field.pageX, + pageY: field.pageY, + pageWidth: field.pageWidth, + pageHeight: field.pageHeight, + })), + }); + + if (body.emailBody || body.emailSubject) { + await upsertDocumentMeta({ + documentId: Number(id), + subject: body.emailSubject ?? '', + message: body.emailBody ?? '', + }); + } + + await sendDocument({ + documentId: Number(id), + userId: user.id, + }); + + return { + status: 200, + body: { + message: 'Document sent for signing successfully', + }, + }; + } catch (err) { + return { + status: 500, + body: { + message: 'An error has occured while sending the document for signing', + }, + }; + } + }), +}); diff --git a/packages/api/v1/middleware/authenticated.ts b/packages/api/v1/middleware/authenticated.ts new file mode 100644 index 000000000..3e23029a5 --- /dev/null +++ b/packages/api/v1/middleware/authenticated.ts @@ -0,0 +1,37 @@ +import type { NextApiRequest } from 'next'; + +import { getUserByApiToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; +import type { User } from '@documenso/prisma/client'; + +export const authenticatedMiddleware = < + T extends { + req: NextApiRequest; + }, + R extends { + status: number; + body: unknown; + }, +>( + handler: (args: T, user: User) => Promise, +) => { + return async (args: T) => { + try { + const { authorization: token } = args.req.headers; + + if (!token) { + throw new Error('Token was not provided for authenticated middleware'); + } + + const user = await getUserByApiToken({ token }); + + return await handler(args, user); + } catch (_err) { + return { + status: 401, + body: { + message: 'Unauthorized', + }, + } as const; + } + }; +}; diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts new file mode 100644 index 000000000..f4c80ca73 --- /dev/null +++ b/packages/api/v1/schema.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const ZGetDocumentsQuerySchema = z.object({ + page: z.string().optional(), + perPage: z.string().optional(), +}); + +export type TGetDocumentsQuerySchema = z.infer; + +export const ZDeleteDocumentMutationSchema = z.string(); + +export type TDeleteDocumentMutationSchema = z.infer; + +export const ZSuccessfulDocumentResponseSchema = z.object({ + id: z.number(), + userId: z.number(), + title: z.string(), + status: z.string(), + documentDataId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + completedAt: z.date().nullable(), +}); + +export type TSuccessfulDocumentResponseSchema = z.infer; + +export const ZSendDocumentForSigningMutationSchema = z.object({ + signerEmail: z.string(), + signerName: z.string().optional(), + emailSubject: z.string().optional(), + emailBody: z.string().optional(), + fields: z.array( + z.object({ + fieldType: z.nativeEnum(FieldType), + pageNumber: z.number(), + pageX: z.number(), + pageY: z.number(), + pageWidth: z.number(), + pageHeight: z.number(), + }), + ), +}); + +export type TSendDocumentForSigningMutationSchema = z.infer< + typeof ZSendDocumentForSigningMutationSchema +>; + +export const ZUploadDocumentSuccessfulSchema = z.object({ + url: z.string(), + key: z.string(), +}); + +export type TUploadDocumentSuccessfulSchema = z.infer; + +export const ZCreateDocumentMutationSchema = z.object({ + fileName: z.string(), + contentType: z.string().default('PDF'), +}); + +export type TCreateDocumentMutationSchema = z.infer; + +export const ZSuccessfulResponseSchema = z.object({ + documents: ZSuccessfulDocumentResponseSchema.array(), + totalPages: z.number(), +}); + +export type TSuccessfulResponseSchema = z.infer; + +export const ZSuccessfulSigningResponseSchema = z.object({ + message: z.string(), +}); + +export type TSuccessfulSigningResponseSchema = z.infer; + +export const ZUnsuccessfulResponseSchema = z.object({ + message: z.string(), +}); + +export type TUnsuccessfulResponseSchema = z.infer; + +export const ZAuthorizationHeadersSchema = z.object({ + authorization: z.string(), +}); + +export type TAuthorizationHeadersSchema = z.infer; diff --git a/packages/lib/server-only/public-api/get-all-user-tokens.ts b/packages/lib/server-only/public-api/get-all-user-tokens.ts index d64562b83..1ba31a6cf 100644 --- a/packages/lib/server-only/public-api/get-all-user-tokens.ts +++ b/packages/lib/server-only/public-api/get-all-user-tokens.ts @@ -5,7 +5,7 @@ export type GetUserTokensOptions = { }; export const getUserTokens = async ({ userId }: GetUserTokensOptions) => { - return prisma.apiToken.findMany({ + return await prisma.apiToken.findMany({ where: { userId, }, @@ -16,5 +16,8 @@ export const getUserTokens = async ({ userId }: GetUserTokensOptions) => { createdAt: true, expires: true, }, + orderBy: { + createdAt: 'desc', + }, }); }; diff --git a/packages/trpc/server/api-token-router/schema.ts b/packages/trpc/server/api-token-router/schema.ts index b615ef3af..c28920b9a 100644 --- a/packages/trpc/server/api-token-router/schema.ts +++ b/packages/trpc/server/api-token-router/schema.ts @@ -4,10 +4,16 @@ export const ZGetApiTokenByIdQuerySchema = z.object({ id: z.number().min(1), }); +export type TGetApiTokenByIdQuerySchema = z.infer; + export const ZCreateTokenMutationSchema = z.object({ tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }), }); +export type TCreateTokenMutationSchema = z.infer; + export const ZDeleteTokenByIdMutationSchema = z.object({ id: z.number().min(1), }); + +export type TDeleteTokenByIdMutationSchema = z.infer; From 0a9006430fe57f17320c874435d0d92fbafc9431 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:40:35 +0530 Subject: [PATCH 030/466] fix: command --- package.json | 2 +- turbo.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 30076100f..59ee798d4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev", "dx:up": "docker compose -f docker/compose-services.yml up -d", "dx:down": "docker compose -f docker/compose-services.yml down", - "ci": "turbo run build test:e2e", + "ci": "turbo run test:e2e", "prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma", "prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma", "prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma", diff --git a/turbo.json b/turbo.json index 3a96c2a07..5bc0ac483 100644 --- a/turbo.json +++ b/turbo.json @@ -27,7 +27,8 @@ "cache": false }, "test:e2e": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "cache": false } }, "globalDependencies": ["**/.env.*local"], From e470020b166abf37975034c73d2efac5a59b305f Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:41:24 +0530 Subject: [PATCH 031/466] feat: add cache build action --- .github/actions/cache-build/action.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/actions/cache-build/action.yml diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml new file mode 100644 index 000000000..6fba4f745 --- /dev/null +++ b/.github/actions/cache-build/action.yml @@ -0,0 +1,25 @@ +name: Cache production build binaries +description: 'Cache or restore if necessary' +inputs: + node_version: + required: false + default: v18.x +runs: + using: 'composite' + steps: + - name: Cache production build + uses: actions/cache@v3 + id: production-build-cache + env: + cache-name: prod-build + with: + path: | + ${{ github.workspace }}/apps/web/.next + ${{ github.workspace }}/apps/marketing/.next + **/.turbo/** + **/dist/** + + key: ${{ runner.os }}-${{ env.cache-name }}-${{ github.run_id }} + + - run: npm run build + if: steps.production-build-cache.outputs.cache-hit != 'true' From fc372d0aa9cb05fe495d1a05536530965ec28fee Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:41:48 +0530 Subject: [PATCH 032/466] feat: add node install action --- .github/actions/node-install/action.yml | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/actions/node-install/action.yml diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml new file mode 100644 index 000000000..92d02092a --- /dev/null +++ b/.github/actions/node-install/action.yml @@ -0,0 +1,33 @@ +name: 'Setup node and cache node_modules' +inputs: + node_version: + required: false + default: v18.x + +runs: + using: 'composite' + steps: + - name: Set up Node ${{ inputs.node_version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + + - name: Cache node modules + id: cache-npm + uses: actions/cache@v3 + env: + cache-name: cache-node-modules + with: + path: ~/.npm + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }} + + - name: Install dependencies + run: | + npm ci + npm run prisma:generate + env: + HUSKY: '0' From 9b5d64cc1a356e67600562f444b9a54aa5064cf8 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:44:27 +0530 Subject: [PATCH 033/466] feat: add playwright action --- .github/actions/playwright-install/action.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/actions/playwright-install/action.yml diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml new file mode 100644 index 000000000..6d0648649 --- /dev/null +++ b/.github/actions/playwright-install/action.yml @@ -0,0 +1,18 @@ +name: Install playwright binaries +description: 'Install playwright, cache and restore if necessary' +runs: + using: 'composite' + steps: + - name: Cache playwright + id: cache-playwright + uses: actions/cache@v3 + with: + path: | + ~/.cache/ms-playwright + ${{ github.workspace }}/node_modules/playwright + key: playwright-${{ hashFiles('**/package-lock.json') }} + restore-keys: playwright- + + - name: Install playwright + if: steps.cache-playwright.outputs.cache-hit != 'true' + run: npx playwright install --with-deps From 9e57de512a8cce6cbb225db7721981dc4f384599 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:46:09 +0530 Subject: [PATCH 034/466] feat: use actions --- .github/workflows/ci.yml | 12 ++---------- .github/workflows/e2e-tests.yml | 17 +++++++---------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deda53ff0..53ed03f20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,20 +23,12 @@ jobs: with: fetch-depth: 2 - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 18 - cache: npm - - - name: Install dependencies - run: npm ci + - uses: ./.github/actions/node-install - name: Copy env run: cp .env.example .env - - name: Build - run: npm run build + - uses: ./.github/actions/cache-build build_docker: name: Build Docker Image diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7b05458d9..9d1782363 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -6,26 +6,21 @@ on: branches: ['main'] jobs: e2e_tests: - name: "E2E Tests" + name: 'E2E Tests' timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 18 - cache: npm - - name: Install dependencies - run: npm ci - name: Copy env run: cp .env.example .env + - uses: ./.github/actions/node-install + - name: Start Services run: npm run dx:up - - name: Install Playwright Browsers - run: npx playwright install --with-deps + - uses: ./.github/actions/playwright-install - name: Generate Prisma Client run: npm run prisma:generate -w @documenso/prisma @@ -36,6 +31,8 @@ jobs: - name: Seed the database run: npm run prisma:seed + - uses: ./.github/actions/cache-build + - name: Run Playwright tests run: npm run ci @@ -43,7 +40,7 @@ jobs: if: always() with: name: test-results - path: "packages/app-tests/**/test-results/*" + path: 'packages/app-tests/**/test-results/*' retention-days: 30 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} From ce6f523230164f830cabcfb4fff224a9db8080dd Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 4 Jan 2024 23:56:32 +0530 Subject: [PATCH 035/466] fix: key --- .github/actions/cache-build/action.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index 6fba4f745..b91332e04 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -12,6 +12,11 @@ runs: id: production-build-cache env: cache-name: prod-build + key-1: ${{ hashFiles('**/package-lock.json') }} + key-2: ${{ github.run_id }} + + # Ensures production-build.yml will always be fresh + key-3: ${{ github.sha }} with: path: | ${{ github.workspace }}/apps/web/.next @@ -19,7 +24,7 @@ runs: **/.turbo/** **/dist/** - key: ${{ runner.os }}-${{ env.cache-name }}-${{ github.run_id }} + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} - run: npm run build if: steps.production-build-cache.outputs.cache-hit != 'true' From b35f050409a3b137e09a0a8d593b30a71e249050 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 00:03:20 +0530 Subject: [PATCH 036/466] fix: add shell --- .github/actions/cache-build/action.yml | 1 + .github/actions/node-install/action.yml | 1 + .github/actions/playwright-install/action.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index b91332e04..cbbe3a0a1 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -27,4 +27,5 @@ runs: key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} - run: npm run build + shell: bash if: steps.production-build-cache.outputs.cache-hit != 'true' diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index 92d02092a..351598182 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -26,6 +26,7 @@ runs: ${{ runner.os }} - name: Install dependencies + shell: bash run: | npm ci npm run prisma:generate diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml index 6d0648649..27d0e66b4 100644 --- a/.github/actions/playwright-install/action.yml +++ b/.github/actions/playwright-install/action.yml @@ -16,3 +16,4 @@ runs: - name: Install playwright if: steps.cache-playwright.outputs.cache-hit != 'true' run: npx playwright install --with-deps + shell: bash From e5b7bf81fa2af9a7add33502044f479d4641ff18 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 00:06:16 +0530 Subject: [PATCH 037/466] fix: add action to codeql --- .github/workflows/codeql-analysis.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 465041c0a..314dc7b7b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,19 +25,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 18 - cache: npm - - - name: Install Dependencies - run: npm ci - - name: Copy env run: cp .env.example .env - - name: Build Documenso - run: npm run build + - uses: ./.github/actions/node-install + + - uses: ./.github/actions/cache-build - name: Initialize CodeQL uses: github/codeql-action/init@v2 From 308f55f3d40df50ec36e0bc9a816af668ed057bb Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 00:09:41 +0530 Subject: [PATCH 038/466] fix: key --- .github/actions/cache-build/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index cbbe3a0a1..9c0b1feae 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -24,7 +24,7 @@ runs: **/.turbo/** **/dist/** - key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} - run: npm run build shell: bash From 26b604dbd0fd6db002b318e6922df997beb44dfd Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 00:21:05 +0530 Subject: [PATCH 039/466] fix: add workflow call --- .github/workflows/ci.yml | 4 +--- .github/workflows/codeql-analysis.yml | 1 + .github/workflows/e2e-tests.yml | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ed03f20..54dec497b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: 'Continuous Integration' on: + workflow_call: push: branches: ['main'] pull_request: @@ -10,9 +11,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true -env: - HUSKY: 0 - jobs: build_app: name: Build App diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 314dc7b7b..873869210 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,6 +1,7 @@ name: 'CodeQL' on: + workflow_call: workflow_dispatch: push: branches: ['main'] diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9d1782363..ad9295ac2 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,5 +1,6 @@ name: Playwright Tests on: + workflow_call: push: branches: ['main'] pull_request: From 0c12e34c38d09ee00ec04edff6c4164cac762399 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:08:32 +0530 Subject: [PATCH 040/466] fix: remove call --- .github/workflows/codeql-analysis.yml | 1 - .github/workflows/e2e-tests.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 873869210..314dc7b7b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,7 +1,6 @@ name: 'CodeQL' on: - workflow_call: workflow_dispatch: push: branches: ['main'] diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index ad9295ac2..9d1782363 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,6 +1,5 @@ name: Playwright Tests on: - workflow_call: push: branches: ['main'] pull_request: From c86f79dd7b68c32d47b051e5745515e6db4385da Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:11:28 +0530 Subject: [PATCH 041/466] feat: add workflow call actions --- .github/workflows/node-install.yml | 16 ++++++++++++++++ .github/workflows/production-build.yml | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 .github/workflows/node-install.yml create mode 100644 .github/workflows/production-build.yml diff --git a/.github/workflows/node-install.yml b/.github/workflows/node-install.yml new file mode 100644 index 000000000..9b1bd52a7 --- /dev/null +++ b/.github/workflows/node-install.yml @@ -0,0 +1,16 @@ +name: Node install + +on: + workflow_call: + +jobs: + setup: + name: Setup Node & cache + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Copy env + run: cp .env.example .env + + - uses: ./.github/actions/node-install diff --git a/.github/workflows/production-build.yml b/.github/workflows/production-build.yml new file mode 100644 index 000000000..e227f2aaa --- /dev/null +++ b/.github/workflows/production-build.yml @@ -0,0 +1,21 @@ +name: Production Build + +on: + workflow_call: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Copy env + run: cp .env.example .env + + - uses: ./.github/actions/node-install + + - uses: ./.github/actions/cache-build From d24b9de254062fff60162bc294ce1d23c15467ab Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:19:22 +0530 Subject: [PATCH 042/466] fix: skip install --- .github/actions/node-install/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index 351598182..947049b62 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -27,6 +27,7 @@ runs: - name: Install dependencies shell: bash + if: steps.cache-npm.outputs.cache-hit != 'true' run: | npm ci npm run prisma:generate From 2bbbe1098a71fab800ea3486c2d381037e816f14 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:32:47 +0530 Subject: [PATCH 043/466] fix: action --- .github/actions/cache-build/action.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index 9c0b1feae..ca7550c76 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -12,11 +12,11 @@ runs: id: production-build-cache env: cache-name: prod-build - key-1: ${{ hashFiles('**/package-lock.json') }} - key-2: ${{ github.run_id }} - + key-1: ${{ inputs.node_version }}-${{ hashFiles('**/package-lock.json') }} + key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }} + key-3: ${{ github.event.pull_request.number || github.ref }} # Ensures production-build.yml will always be fresh - key-3: ${{ github.sha }} + key-4: ${{ github.sha }} with: path: | ${{ github.workspace }}/apps/web/.next @@ -24,7 +24,7 @@ runs: **/.turbo/** **/dist/** - key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }} + key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} - run: npm run build shell: bash From 634807328ed6b14c344a0f15decbe62c03157438 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:38:14 +0530 Subject: [PATCH 044/466] fix: command --- .github/actions/node-install/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index 947049b62..351598182 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -27,7 +27,6 @@ runs: - name: Install dependencies shell: bash - if: steps.cache-npm.outputs.cache-hit != 'true' run: | npm ci npm run prisma:generate From 75630ef19d3329c03d85b44dd905152c00a7e072 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 01:58:00 +0530 Subject: [PATCH 045/466] fix: npm action --- .github/actions/node-install/action.yml | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index 351598182..77483a9a4 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -12,23 +12,28 @@ runs: with: node-version: ${{ inputs.node_version }} - - name: Cache node modules - id: cache-npm + - name: Cache npm uses: actions/cache@v3 - env: - cache-name: cache-node-modules with: path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }} + key: npm-${{ hashFiles('package-lock.json') }} + restore-keys: npm- + + - name: Cache node_modules + uses: actions/cache@v3 + id: cache-node-modules + with: + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: modules-${{ hashFiles('package-lock.json') }} - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' shell: bash run: | - npm ci + npm ci --no-audit npm run prisma:generate env: HUSKY: '0' From c8337d7dcc2def5bbaddf190867d946fe1a29b37 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 02:13:42 +0530 Subject: [PATCH 046/466] fix: key --- .github/actions/cache-build/action.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index ca7550c76..b903e8b07 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -10,13 +10,6 @@ runs: - name: Cache production build uses: actions/cache@v3 id: production-build-cache - env: - cache-name: prod-build - key-1: ${{ inputs.node_version }}-${{ hashFiles('**/package-lock.json') }} - key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }} - key-3: ${{ github.event.pull_request.number || github.ref }} - # Ensures production-build.yml will always be fresh - key-4: ${{ github.sha }} with: path: | ${{ github.workspace }}/apps/web/.next @@ -24,7 +17,8 @@ runs: **/.turbo/** **/dist/** - key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }} + key: prod-build-${{ github.run_id }} + restore-keys: prod-build- - run: npm run build shell: bash From 346078dbbe7672c181214821b671f2723b130d90 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 02:15:27 +0530 Subject: [PATCH 047/466] fix: e2e --- .github/actions/cache-build/action.yml | 1 - .github/workflows/e2e-tests.yml | 3 --- 2 files changed, 4 deletions(-) diff --git a/.github/actions/cache-build/action.yml b/.github/actions/cache-build/action.yml index b903e8b07..e1eb4da22 100644 --- a/.github/actions/cache-build/action.yml +++ b/.github/actions/cache-build/action.yml @@ -22,4 +22,3 @@ runs: - run: npm run build shell: bash - if: steps.production-build-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9d1782363..12a7d9521 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -22,9 +22,6 @@ jobs: - uses: ./.github/actions/playwright-install - - name: Generate Prisma Client - run: npm run prisma:generate -w @documenso/prisma - - name: Create the database run: npm run prisma:migrate-dev From 8eed13e27520206627cce61c5d1771b08a93fc76 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Fri, 5 Jan 2024 02:22:42 +0530 Subject: [PATCH 048/466] fix: remove additional workflow --- .github/workflows/node-install.yml | 16 ---------------- .github/workflows/production-build.yml | 21 --------------------- 2 files changed, 37 deletions(-) delete mode 100644 .github/workflows/node-install.yml delete mode 100644 .github/workflows/production-build.yml diff --git a/.github/workflows/node-install.yml b/.github/workflows/node-install.yml deleted file mode 100644 index 9b1bd52a7..000000000 --- a/.github/workflows/node-install.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Node install - -on: - workflow_call: - -jobs: - setup: - name: Setup Node & cache - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Copy env - run: cp .env.example .env - - - uses: ./.github/actions/node-install diff --git a/.github/workflows/production-build.yml b/.github/workflows/production-build.yml deleted file mode 100644 index e227f2aaa..000000000 --- a/.github/workflows/production-build.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Production Build - -on: - workflow_call: - -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Copy env - run: cp .env.example .env - - - uses: ./.github/actions/node-install - - - uses: ./.github/actions/cache-build From 6d1ad179d4b6cca4f835cc23e4a646f6f729ec7d Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 13:30:21 +0530 Subject: [PATCH 049/466] feat: add clean cache workflow --- .github/workflows/clean-cache.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/clean-cache.yml diff --git a/.github/workflows/clean-cache.yml b/.github/workflows/clean-cache.yml new file mode 100644 index 000000000..2cb13f661 --- /dev/null +++ b/.github/workflows/clean-cache.yml @@ -0,0 +1,29 @@ +name: cleanup caches by a branch +on: + pull_request: + types: + - closed + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Cleanup + run: | + gh extension install actions/gh-actions-cache + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR + do + gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge From 3eb1a17d3c1be45f345967c749ea800419409d81 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 14:42:49 +0530 Subject: [PATCH 050/466] chore: force build error --- apps/web/src/app/(dashboard)/layout.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 433aeb18c..40831549b 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -2,8 +2,6 @@ import React from 'react'; import { redirect } from 'next/navigation'; -import { getServerSession } from 'next-auth'; - import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; From 142c93aa630c20622cfd31ae2cbd5383e2c006a6 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 14:45:44 +0530 Subject: [PATCH 051/466] chore: revert force build error --- apps/web/src/app/(dashboard)/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 40831549b..433aeb18c 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { redirect } from 'next/navigation'; +import { getServerSession } from 'next-auth'; + import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; From 46e83d65bb3caaba4904d207750f9f66420ef9cd Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 15:14:28 +0530 Subject: [PATCH 052/466] feat: cache docker --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54dec497b..117064721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,5 +37,13 @@ jobs: with: fetch-depth: 2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Docker Image - run: ./docker/build.sh + uses: docker/build-push-action@v5 + with: + push: false + context: . + file: ./docker/Dockerfile + tags: documenso-${{ github.sha }} From ba37633ecd2a6aabce35b9250f8008d993953c74 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 15:18:09 +0530 Subject: [PATCH 053/466] fix: revert --- .github/workflows/ci.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 117064721..54dec497b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,13 +37,5 @@ jobs: with: fetch-depth: 2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build Docker Image - uses: docker/build-push-action@v5 - with: - push: false - context: . - file: ./docker/Dockerfile - tags: documenso-${{ github.sha }} + run: ./docker/build.sh From 34a59d2db3324b90044594be7a2ed46b4f6d25f2 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 15:18:33 +0530 Subject: [PATCH 054/466] fix: cache --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54dec497b..117064721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,5 +37,13 @@ jobs: with: fetch-depth: 2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Docker Image - run: ./docker/build.sh + uses: docker/build-push-action@v5 + with: + push: false + context: . + file: ./docker/Dockerfile + tags: documenso-${{ github.sha }} From 60651407157fd93153c5ace69b3b9b8689f32c58 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 6 Jan 2024 15:29:19 +0530 Subject: [PATCH 055/466] feat: cache layers --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 117064721..bebca8e85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Build Docker Image uses: docker/build-push-action@v5 with: @@ -47,3 +55,13 @@ jobs: context: . file: ./docker/Dockerfile tags: documenso-${{ github.sha }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max + + - # Temp fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache From 3b82ba57f39cfb8cdc6b1387b448cd8264c96490 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 17 Jan 2024 12:44:25 +0200 Subject: [PATCH 056/466] chore: implemented feedback plus some restructuring --- .../app/(dashboard)/settings/{token => tokens}/page.tsx | 0 .../components/(dashboard)/layout/profile-dropdown.tsx | 8 ++++++++ .../(dashboard)/settings/layout/desktop-nav.tsx | 4 ++-- .../components/(dashboard)/settings/layout/mobile-nav.tsx | 4 ++-- .../(dashboard)/settings/token/delete-token-dialog.tsx | 3 ++- packages/lib/server-only/public-api/create-api-token.ts | 2 +- 6 files changed, 15 insertions(+), 6 deletions(-) rename apps/web/src/app/(dashboard)/settings/{token => tokens}/page.tsx (100%) diff --git a/apps/web/src/app/(dashboard)/settings/token/page.tsx b/apps/web/src/app/(dashboard)/settings/tokens/page.tsx similarity index 100% rename from apps/web/src/app/(dashboard)/settings/token/page.tsx rename to apps/web/src/app/(dashboard)/settings/tokens/page.tsx diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index e488ba6e9..7c06557b1 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { + Braces, CreditCard, Lock, LogOut, @@ -97,6 +98,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { + + + + API Tokens + + + {isBillingEnabled && ( diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index 848ff17cc..8bc395121 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -48,12 +48,12 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - + - +
- + +
+ + + + Delete your account and all its contents, including completed documents. This action is + irreversible and will cancel your subscription, so proceed with caution. + + + + + + + + + Delete Account + + Documenso will delete{' '} + all of your documents, along with all of + your completed documents, signatures, and all other resources belonging to your + Account. + + + + + Cancel + + Delete Account + + + + + + +
); }; + +export function AlertDestructive() { + return ( + + + This action is not reversible. Please be certain. + + + ); +} diff --git a/packages/ui/primitives/alert.tsx b/packages/ui/primitives/alert.tsx index 190f7781d..5409152b7 100644 --- a/packages/ui/primitives/alert.tsx +++ b/packages/ui/primitives/alert.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; -import { VariantProps, cva } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; import { cn } from '../lib/utils'; From a3e560899a82521c1edbfa75e0d259f3f12d9ac1 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sat, 20 Jan 2024 23:30:56 +0000 Subject: [PATCH 060/466] feat: delete user from db and unsubscribe from stripe --- .devcontainer/devcontainer.json | 12 +++- apps/web/src/components/forms/profile.tsx | 58 +++++++++++++++---- .../ee/server-only/stripe/delete-customer.ts | 10 ++++ packages/trpc/react/index.tsx | 2 +- packages/trpc/server/auth-router/schema.ts | 6 ++ packages/trpc/server/profile-router/router.ts | 25 ++++++++ 6 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 packages/ee/server-only/stripe/delete-customer.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 60b385403..3471f4f88 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,7 +10,13 @@ "ghcr.io/devcontainers/features/node:1": {} }, "onCreateCommand": "./.devcontainer/on-create.sh", - "forwardPorts": [3000, 54320, 9000, 2500, 1100], + "forwardPorts": [ + 3000, + 54320, + 9000, + 2500, + 1100 + ], "customizations": { "vscode": { "extensions": [ @@ -25,8 +31,8 @@ "GitHub.copilot", "GitHub.vscode-pull-request-github", "Prisma.prisma", - "VisualStudioExptTeam.vscodeintellicode", + "VisualStudioExptTeam.vscodeintellicode" ] } } -} +} \ No newline at end of file diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 78da0f636..b36df4e7f 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; +import { signOut } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -65,6 +66,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const isSubmitting = form.formState.isSubmitting; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); + const { mutateAsync: deleteAccount } = trpc.profile.deleteAccount.useMutation(); const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { try { @@ -98,6 +100,39 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; + const onDeleteAccount = async () => { + try { + await deleteAccount(); + + await signOut({ callbackUrl: '/' }); + + toast({ + title: 'Account deleted', + description: 'Your account has been deleted successfully.', + duration: 5000, + }); + + // logout after deleting account + + router.push('/'); + } catch (err) { + if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: err.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to delete your account. Please try again later.', + }); + } + } + }; + return (
{ all of your documents, along with all of your completed documents, signatures, and all other resources belonging to your Account. - + + + This action is not reversible. Please be certain. + + Cancel - + Delete Account @@ -189,12 +231,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { ); }; -export function AlertDestructive() { - return ( - - - This action is not reversible. Please be certain. - - - ); -} +// Cal.com Delete User TRPC = https://github.com/calcom/cal.com/blob/main/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts#L11 +// https://github.com/calcom/cal.com/blob/main/packages/features/users/lib/userDeletionService.ts#L7 +// delete stripe: https://github.com/calcom/cal.com/blob/main/packages/app-store/stripepayment/lib/customer.ts#L72 diff --git a/packages/ee/server-only/stripe/delete-customer.ts b/packages/ee/server-only/stripe/delete-customer.ts new file mode 100644 index 000000000..16120de68 --- /dev/null +++ b/packages/ee/server-only/stripe/delete-customer.ts @@ -0,0 +1,10 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; +import type { User } from '@documenso/prisma/client'; + +export const deleteStripeCustomer = async (user: User) => { + if (!user.customerId) { + return null; + } + + return await stripe.customers.del(user.customerId); +}; diff --git a/packages/trpc/react/index.tsx b/packages/trpc/react/index.tsx index 85161d0e8..ce80ba267 100644 --- a/packages/trpc/react/index.tsx +++ b/packages/trpc/react/index.tsx @@ -9,7 +9,7 @@ import SuperJSON from 'superjson'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; -import { AppRouter } from '../server/router'; +import type { AppRouter } from '../server/router'; export const trpc = createTRPCReact({ unstable_overrides: { diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index cc969c679..f342c25fb 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -10,3 +10,9 @@ export const ZSignUpMutationSchema = z.object({ export type TSignUpMutationSchema = z.infer; export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true }); + +export const ZDeleteAccountMutationSchema = z.object({ + email: z.string().email(), +}); + +export type TDeleteAccountMutationSchema = z.infer; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 4dcf4ca93..cf5fdbf94 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,5 +1,7 @@ import { TRPCError } from '@trpc/server'; +import { deleteStripeCustomer } from '@documenso/ee/server-only/stripe/delete-customer'; +import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { resetPassword } from '@documenso/lib/server-only/user/reset-password'; @@ -133,4 +135,27 @@ export const profileRouter = router({ }); } }), + + deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { + try { + const user = ctx.user; + + const deletedUser = await deleteStripeCustomer(user); + + console.log(deletedUser); + + return await deleteUser(user); + } catch (err) { + let message = 'We were unable to delete your account. Please try again.'; + + if (err instanceof Error) { + message = err.message; + } + + throw new TRPCError({ + code: 'BAD_REQUEST', + message, + }); + } + }), }); From 7762b1db6593e80af92bc854989828f53d2545a6 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sun, 21 Jan 2024 09:47:50 +0000 Subject: [PATCH 061/466] feat: add loading to button --- apps/web/src/components/forms/profile.tsx | 63 +++++++++++------------ 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index b36df4e7f..7e274ff8e 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -12,19 +12,17 @@ import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@documenso/ui/primitives/alert-dialog'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardFooter } from '@documenso/ui/primitives/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; import { Form, FormControl, @@ -66,7 +64,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const isSubmitting = form.formState.isSubmitting; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); - const { mutateAsync: deleteAccount } = trpc.profile.deleteAccount.useMutation(); + const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = + trpc.profile.deleteAccount.useMutation(); const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { try { @@ -194,14 +193,14 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { irreversible and will cancel your subscription, so proceed with caution. - - + + - - - - Delete Account - + + + + Delete Account + Documenso will delete{' '} all of your documents, along with all of your completed documents, signatures, and all other resources belonging to your @@ -211,26 +210,22 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { This action is not reversible. Please be certain. - - - - Cancel - + + + + + + ); }; - -// Cal.com Delete User TRPC = https://github.com/calcom/cal.com/blob/main/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts#L11 -// https://github.com/calcom/cal.com/blob/main/packages/features/users/lib/userDeletionService.ts#L7 -// delete stripe: https://github.com/calcom/cal.com/blob/main/packages/app-store/stripepayment/lib/customer.ts#L72 From 9e433af1126c2d0f7665d0cb827cd16ffe9fff91 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sun, 21 Jan 2024 15:38:32 +0000 Subject: [PATCH 062/466] feat: require 2fa code before account is deleted --- apps/web/src/components/forms/profile.tsx | 123 +++++++++++++----- packages/lib/server-only/2fa/setup-2fa.ts | 2 +- packages/lib/server-only/2fa/validate-2fa.ts | 2 +- .../lib/server-only/2fa/verify-2fa-token.ts | 3 +- packages/ui/primitives/button.tsx | 3 +- 5 files changed, 98 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 7e274ff8e..575a81d46 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -7,6 +7,7 @@ import { signOut } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa'; import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; @@ -41,6 +42,11 @@ export const ZProfileFormSchema = z.object({ signature: z.string().min(1, 'Signature Pad cannot be empty'), }); +export const ZTwoFactorAuthTokenSchema = z.object({ + token: z.string(), +}); + +export type TTwoFactorAuthTokenSchema = z.infer; export type TProfileFormSchema = z.infer; export type ProfileFormProps = { @@ -61,7 +67,15 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { resolver: zodResolver(ZProfileFormSchema), }); + const deleteAccountTwoFactorTokenForm = useForm({ + defaultValues: { + token: '', + }, + resolver: zodResolver(ZTwoFactorAuthTokenSchema), + }); + const isSubmitting = form.formState.isSubmitting; + const hasTwoFactorAuthentication = user.twoFactorEnabled; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = @@ -101,9 +115,20 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const onDeleteAccount = async () => { try { - await deleteAccount(); + const { token } = deleteAccountTwoFactorTokenForm.getValues(); - await signOut({ callbackUrl: '/' }); + if (!token) { + throw new Error('Please enter your Two Factor Authentication token.'); + } + + await validateTwoFactorAuthentication({ + totpCode: token, + user, + }).catch(() => { + throw new Error('We were unable to validate your Two Factor Authentication token.'); + }); + + await deleteAccount(); toast({ title: 'Account deleted', @@ -111,9 +136,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { duration: 5000, }); - // logout after deleting account - - router.push('/'); + await signOut({ callbackUrl: '/' }); } catch (err) { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({ @@ -126,6 +149,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { title: 'An unknown error occurred', variant: 'destructive', description: + err.message ?? 'We encountered an unknown error while attempting to delete your account. Please try again later.', }); } @@ -193,36 +217,73 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { irreversible and will cancel your subscription, so proceed with caution. - - - - - - - Delete Account - - Documenso will delete{' '} - all of your documents, along with all of - your completed documents, signatures, and all other resources belonging to your - Account. - +
+ { + console.log('delete account'); + })} + > + + + + + + + Delete Account + + Documenso will delete{' '} + all of your documents, along with all + of your completed documents, signatures, and all other resources belonging + to your Account. + + + + This action is not reversible. Please be certain. - - - - - - - + + {hasTwoFactorAuthentication && ( +
+ ( + + + Two Factor Authentication Token + + + + + + + )} + /> +
+ )} + + + + + +
+ +
diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts index 30ddf0ec3..a60b0934b 100644 --- a/packages/lib/server-only/2fa/setup-2fa.ts +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { prisma } from '@documenso/prisma'; -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { symmetricEncrypt } from '../../universal/crypto'; diff --git a/packages/lib/server-only/2fa/validate-2fa.ts b/packages/lib/server-only/2fa/validate-2fa.ts index 7fc76a8bb..33141c325 100644 --- a/packages/lib/server-only/2fa/validate-2fa.ts +++ b/packages/lib/server-only/2fa/validate-2fa.ts @@ -1,4 +1,4 @@ -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { ErrorCode } from '../../next-auth/error-codes'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; diff --git a/packages/lib/server-only/2fa/verify-2fa-token.ts b/packages/lib/server-only/2fa/verify-2fa-token.ts index fa9159517..3c410bd58 100644 --- a/packages/lib/server-only/2fa/verify-2fa-token.ts +++ b/packages/lib/server-only/2fa/verify-2fa-token.ts @@ -1,7 +1,7 @@ import { base32 } from '@scure/base'; import { TOTPController } from 'oslo/otp'; -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { symmetricDecrypt } from '../../universal/crypto'; @@ -17,6 +17,7 @@ export const verifyTwoFactorAuthenticationToken = async ({ user, totpCode, }: VerifyTwoFactorAuthenticationTokenOptions) => { + // TODO: This is undefined and I can't figure out why. const key = DOCUMENSO_ENCRYPTION_KEY; if (!user.twoFactorSecret) { diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index 5754b35a5..68ecb6eb0 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -13,7 +13,8 @@ const buttonVariants = cva( variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive', outline: 'border border-input hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', From 98667dac15235a937d9effbce218aa2a50466108 Mon Sep 17 00:00:00 2001 From: Gautam-Hegde Date: Mon, 22 Jan 2024 12:03:14 +0530 Subject: [PATCH 063/466] chore: code tidy --- .../src/components/(dashboard)/layout/verify-email-banner.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx index 24e47c186..43eab21c5 100644 --- a/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx +++ b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { AlertTriangle } from 'lucide-react'; -import { ONE_SECOND } from '@documenso/lib/constants/time'; +import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -65,7 +65,7 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => { if (emailVerificationDialogLastShown) { const lastShownTimestamp = parseInt(emailVerificationDialogLastShown); - if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) { + if (Date.now() - lastShownTimestamp < ONE_DAY) { return; } } From 5a28eaa4ff6477fda00776931e2076783c08d7d5 Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 22 Jan 2024 17:38:02 +1100 Subject: [PATCH 064/466] feat: add recipient creation --- apps/web/src/pages/api/v1/[...ts-rest].tsx | 14 +- package-lock.json | 19 ++- packages/api/package.json | 1 + packages/api/v1/api-documentation.tsx | 2 + packages/api/v1/contract.ts | 28 +++- packages/api/v1/implementation.ts | 141 ++++++++++++++---- packages/api/v1/schema.ts | 50 ++++--- .../public-api/get-api-token-by-id.ts | 2 +- packages/trpc/api-contract/contract.ts | 80 ---------- packages/trpc/api-contract/schema.ts | 65 -------- packages/trpc/server/public-api/ts-rest.ts | 3 - 11 files changed, 187 insertions(+), 218 deletions(-) delete mode 100644 packages/trpc/api-contract/contract.ts delete mode 100644 packages/trpc/api-contract/schema.ts delete mode 100644 packages/trpc/server/public-api/ts-rest.ts diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index 15b618ebd..095936cf0 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -1,5 +1,17 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + import { createNextRouter } from '@documenso/api/next'; import { ApiContractV1 } from '@documenso/api/v1/contract'; import { ApiContractV1Implementation } from '@documenso/api/v1/implementation'; -export default createNextRouter(ApiContractV1, ApiContractV1Implementation); +const nextRouteHandler = createNextRouter(ApiContractV1, ApiContractV1Implementation, { + responseValidation: false, +}); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // TODO: Dirty hack to make ts-rest handler work with next.js in a more intuitive way. + req.query['ts-rest'] = Array.isArray(req.query['ts-rest']) ? req.query['ts-rest'] : []; // Make `ts-rest` an array. + req.query['ts-rest'].unshift('api', 'v1'); // Prepend our base path to the array. + + return await nextRouteHandler(req, res); +} diff --git a/package-lock.json b/package-lock.json index 8aa403089..decf0485d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6718,8 +6718,7 @@ "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "devOptional": true + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/ramda": { "version": "0.29.9", @@ -6733,7 +6732,6 @@ "version": "18.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.18.tgz", "integrity": "sha512-da4NTSeBv/P34xoZPhtcLkmZuJ+oYaCxHmyHzwaDQo9RQPBeXV+06gEk2FpqEcsX9XrnNLvRpVh6bdavDSjtiQ==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -6757,14 +6755,21 @@ "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "devOptional": true + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==" }, + "node_modules/@types/swagger-ui-react": { + "version": "4.18.3", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-4.18.3.tgz", + "integrity": "sha512-Mo/R7IjDVwtiFPs84pWvh5pI9iyNGBjmfielxqbOh2Jv+8WVSDVe8Nu25kb5BOuV2xmGS3o33jr6nwDJMBcX+Q==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/unist": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", @@ -8729,8 +8734,7 @@ "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "devOptional": true + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/d3-array": { "version": "3.2.4", @@ -20739,6 +20743,7 @@ "@ts-rest/core": "^3.30.5", "@ts-rest/next": "^3.30.5", "@ts-rest/open-api": "^3.33.0", + "@types/swagger-ui-react": "^4.18.3", "luxon": "^3.4.0", "superjson": "^1.13.1", "swagger-ui-react": "^5.11.0", diff --git a/packages/api/package.json b/packages/api/package.json index 1d9b5c159..aebb09c9b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -20,6 +20,7 @@ "@ts-rest/core": "^3.30.5", "@ts-rest/next": "^3.30.5", "@ts-rest/open-api": "^3.33.0", + "@types/swagger-ui-react": "^4.18.3", "luxon": "^3.4.0", "superjson": "^1.13.1", "swagger-ui-react": "^5.11.0", diff --git a/packages/api/v1/api-documentation.tsx b/packages/api/v1/api-documentation.tsx index 6f8062271..6082d2d7f 100644 --- a/packages/api/v1/api-documentation.tsx +++ b/packages/api/v1/api-documentation.tsx @@ -1,3 +1,5 @@ +'use client'; + import SwaggerUI from 'swagger-ui-react'; import 'swagger-ui-react/swagger-ui.css'; diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index 0f853a020..438fa9cee 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -4,9 +4,11 @@ import { ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema, ZAuthorizationHeadersSchema, ZCreateDocumentMutationSchema, + ZCreateRecipientMutationSchema, ZDeleteDocumentMutationSchema, ZGetDocumentsQuerySchema, ZSuccessfulDocumentResponseSchema, + ZSuccessfulRecipientResponseSchema, ZSuccessfulResponseSchema, ZSuccessfulSigningResponseSchema, ZUnsuccessfulResponseSchema, @@ -19,7 +21,7 @@ export const ApiContractV1 = c.router( { getDocuments: { method: 'GET', - path: '/documents', + path: '/api/v1/documents', query: ZGetDocumentsQuerySchema, responses: { 200: ZSuccessfulResponseSchema, @@ -31,7 +33,7 @@ export const ApiContractV1 = c.router( getDocument: { method: 'GET', - path: `/documents/:id`, + path: '/api/v1/documents/:id', responses: { 200: ZSuccessfulDocumentResponseSchema, 401: ZUnsuccessfulResponseSchema, @@ -42,7 +44,7 @@ export const ApiContractV1 = c.router( createDocument: { method: 'POST', - path: '/documents', + path: '/api/v1/documents', body: ZCreateDocumentMutationSchema, responses: { 200: ZUploadDocumentSuccessfulSchema, @@ -53,8 +55,8 @@ export const ApiContractV1 = c.router( }, sendDocument: { - method: 'PATCH', - path: '/documents/:id/send', + method: 'POST', + path: '/api/v1/documents/:id/send', body: SendDocumentMutationSchema, responses: { 200: ZSuccessfulSigningResponseSchema, @@ -68,7 +70,7 @@ export const ApiContractV1 = c.router( deleteDocument: { method: 'DELETE', - path: `/documents/:id`, + path: '/api/v1/documents/:id', body: ZDeleteDocumentMutationSchema, responses: { 200: ZSuccessfulDocumentResponseSchema, @@ -77,6 +79,20 @@ export const ApiContractV1 = c.router( }, summary: 'Delete a document', }, + + createRecipient: { + method: 'POST', + path: '/api/v1/documents/:id/recipients', + body: ZCreateRecipientMutationSchema, + responses: { + 200: ZSuccessfulRecipientResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Create a recipient for a document', + }, }, { baseHeaders: ZAuthorizationHeadersSchema, diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index b317e95d6..4dd709246 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -1,13 +1,13 @@ import { createNextRoute } from '@ts-rest/next'; -import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; -import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; +import { DocumentStatus } from '@documenso/prisma/client'; import { ApiContractV1 } from './contract'; import { authenticatedMiddleware } from './middleware/authenticated'; @@ -99,7 +99,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { sendDocument: authenticatedMiddleware(async (args, user) => { const { id } = args.params; - const { body } = args; const document = await getDocumentById({ id: Number(id), userId: user.id }); @@ -122,38 +121,38 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } try { - await setRecipientsForDocument({ - userId: user.id, - documentId: Number(id), - recipients: [ - { - email: body.signerEmail, - name: body.signerName ?? '', - }, - ], - }); + // await setRecipientsForDocument({ + // userId: user.id, + // documentId: Number(id), + // recipients: [ + // { + // email: body.signerEmail, + // name: body.signerName ?? '', + // }, + // ], + // }); - await setFieldsForDocument({ - documentId: Number(id), - userId: user.id, - fields: body.fields.map((field) => ({ - signerEmail: body.signerEmail, - type: field.fieldType, - pageNumber: field.pageNumber, - pageX: field.pageX, - pageY: field.pageY, - pageWidth: field.pageWidth, - pageHeight: field.pageHeight, - })), - }); + // await setFieldsForDocument({ + // documentId: Number(id), + // userId: user.id, + // fields: body.fields.map((field) => ({ + // signerEmail: body.signerEmail, + // type: field.fieldType, + // pageNumber: field.pageNumber, + // pageX: field.pageX, + // pageY: field.pageY, + // pageWidth: field.pageWidth, + // pageHeight: field.pageHeight, + // })), + // }); - if (body.emailBody || body.emailSubject) { - await upsertDocumentMeta({ - documentId: Number(id), - subject: body.emailSubject ?? '', - message: body.emailBody ?? '', - }); - } + // if (body.emailBody || body.emailSubject) { + // await upsertDocumentMeta({ + // documentId: Number(id), + // subject: body.emailSubject ?? '', + // message: body.emailBody ?? '', + // }); + // } await sendDocument({ documentId: Number(id), @@ -175,4 +174,80 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; } }), + + createRecipient: authenticatedMiddleware(async (args, user) => { + const { id: documentId } = args.params; + const { name, email } = args.body; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already completed', + }, + }; + } + + const recipients = await getRecipientsForDocument({ + documentId: Number(documentId), + userId: user.id, + }); + + const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email); + + if (recipientAlreadyExists) { + return { + status: 400, + body: { + message: 'Recipient already exists', + }, + }; + } + + try { + const newRecipients = await setRecipientsForDocument({ + documentId: Number(documentId), + userId: user.id, + recipients: [ + ...recipients, + { + email, + name, + }, + ], + }); + + const newRecipient = newRecipients.find((recipient) => recipient.email === email); + + if (!newRecipient) { + throw new Error('Recipient not found'); + } + + return { + status: 200, + body: newRecipient, + }; + } catch (err) { + return { + status: 500, + body: { + message: 'An error has occured while creating the recipient', + }, + }; + } + }), }); diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index f4c80ca73..91c35ce28 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { FieldType } from '@documenso/prisma/client'; +import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; export const ZGetDocumentsQuerySchema = z.object({ page: z.string().optional(), @@ -9,9 +9,9 @@ export const ZGetDocumentsQuerySchema = z.object({ export type TGetDocumentsQuerySchema = z.infer; -export const ZDeleteDocumentMutationSchema = z.string(); +export const ZDeleteDocumentMutationSchema = null; -export type TDeleteDocumentMutationSchema = z.infer; +export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema; export const ZSuccessfulDocumentResponseSchema = z.object({ id: z.number(), @@ -26,26 +26,9 @@ export const ZSuccessfulDocumentResponseSchema = z.object({ export type TSuccessfulDocumentResponseSchema = z.infer; -export const ZSendDocumentForSigningMutationSchema = z.object({ - signerEmail: z.string(), - signerName: z.string().optional(), - emailSubject: z.string().optional(), - emailBody: z.string().optional(), - fields: z.array( - z.object({ - fieldType: z.nativeEnum(FieldType), - pageNumber: z.number(), - pageX: z.number(), - pageY: z.number(), - pageWidth: z.number(), - pageHeight: z.number(), - }), - ), -}); +export const ZSendDocumentForSigningMutationSchema = null; -export type TSendDocumentForSigningMutationSchema = z.infer< - typeof ZSendDocumentForSigningMutationSchema ->; +export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema; export const ZUploadDocumentSuccessfulSchema = z.object({ url: z.string(), @@ -61,6 +44,29 @@ export const ZCreateDocumentMutationSchema = z.object({ export type TCreateDocumentMutationSchema = z.infer; +export const ZCreateRecipientMutationSchema = z.object({ + name: z.string().min(1), + email: z.string().email().min(1), +}); + +export type TCreateRecipientMutationSchema = z.infer; + +export const ZSuccessfulRecipientResponseSchema = z.object({ + id: z.number(), + documentId: z.number(), + email: z.string().email().min(1), + name: z.string(), + token: z.string(), + // !: Not used for now + // expired: z.string(), + signedAt: z.date().nullable(), + readStatus: z.nativeEnum(ReadStatus), + signingStatus: z.nativeEnum(SigningStatus), + sendStatus: z.nativeEnum(SendStatus), +}); + +export type TSuccessfulRecipientResponseSchema = z.infer; + export const ZSuccessfulResponseSchema = z.object({ documents: ZSuccessfulDocumentResponseSchema.array(), totalPages: z.number(), diff --git a/packages/lib/server-only/public-api/get-api-token-by-id.ts b/packages/lib/server-only/public-api/get-api-token-by-id.ts index ae442f05e..8b25717f9 100644 --- a/packages/lib/server-only/public-api/get-api-token-by-id.ts +++ b/packages/lib/server-only/public-api/get-api-token-by-id.ts @@ -6,7 +6,7 @@ export type GetApiTokenByIdOptions = { }; export const getApiTokenById = async ({ id, userId }: GetApiTokenByIdOptions) => { - return prisma.apiToken.findFirstOrThrow({ + return await prisma.apiToken.findFirstOrThrow({ where: { id, userId, diff --git a/packages/trpc/api-contract/contract.ts b/packages/trpc/api-contract/contract.ts deleted file mode 100644 index 5f28a0c63..000000000 --- a/packages/trpc/api-contract/contract.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { initContract } from '@ts-rest/core'; - -import { - AuthorizationHeadersSchema, - CreateDocumentMutationSchema, - DeleteDocumentMutationSchema, - GetDocumentsQuerySchema, - SendDocumentForSigningMutationSchema, - SuccessfulDocumentResponseSchema, - SuccessfulResponseSchema, - SuccessfulSigningResponseSchema, - UnsuccessfulResponseSchema, - UploadDocumentSuccessfulSchema, -} from './schema'; - -const c = initContract(); - -export const contract = c.router( - { - getDocuments: { - method: 'GET', - path: '/documents', - query: GetDocumentsQuerySchema, - responses: { - 200: SuccessfulResponseSchema, - 401: UnsuccessfulResponseSchema, - 404: UnsuccessfulResponseSchema, - }, - summary: 'Get all documents', - }, - getDocument: { - method: 'GET', - path: `/documents/:id`, - responses: { - 200: SuccessfulDocumentResponseSchema, - 401: UnsuccessfulResponseSchema, - 404: UnsuccessfulResponseSchema, - }, - summary: 'Get a single document', - }, - createDocument: { - method: 'POST', - path: '/documents', - body: CreateDocumentMutationSchema, - responses: { - 200: UploadDocumentSuccessfulSchema, - 401: UnsuccessfulResponseSchema, - 404: UnsuccessfulResponseSchema, - }, - summary: 'Upload a new document and get a presigned URL', - }, - sendDocumentForSigning: { - method: 'PATCH', - path: '/documents/:id/send', - body: SendDocumentForSigningMutationSchema, - responses: { - 200: SuccessfulSigningResponseSchema, - 400: UnsuccessfulResponseSchema, - 401: UnsuccessfulResponseSchema, - 404: UnsuccessfulResponseSchema, - 500: UnsuccessfulResponseSchema, - }, - summary: 'Send a document for signing', - }, - deleteDocument: { - method: 'DELETE', - path: `/documents/:id`, - body: DeleteDocumentMutationSchema, - responses: { - 200: SuccessfulDocumentResponseSchema, - 401: UnsuccessfulResponseSchema, - 404: UnsuccessfulResponseSchema, - }, - summary: 'Delete a document', - }, - }, - { - baseHeaders: AuthorizationHeadersSchema, - }, -); diff --git a/packages/trpc/api-contract/schema.ts b/packages/trpc/api-contract/schema.ts deleted file mode 100644 index d62d50d52..000000000 --- a/packages/trpc/api-contract/schema.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from 'zod'; - -import { FieldType } from '@documenso/prisma/client'; - -export const GetDocumentsQuerySchema = z.object({ - page: z.string().optional(), - perPage: z.string().optional(), -}); - -export const DeleteDocumentMutationSchema = z.string(); - -export const SuccessfulDocumentResponseSchema = z.object({ - id: z.number(), - userId: z.number(), - title: z.string(), - status: z.string(), - documentDataId: z.string(), - createdAt: z.date(), - updatedAt: z.date(), - completedAt: z.date().nullable(), -}); - -export const SendDocumentForSigningMutationSchema = z.object({ - signerEmail: z.string(), - signerName: z.string().optional(), - emailSubject: z.string().optional(), - emailBody: z.string().optional(), - fields: z.array( - z.object({ - fieldType: z.nativeEnum(FieldType), - pageNumber: z.number(), - pageX: z.number(), - pageY: z.number(), - pageWidth: z.number(), - pageHeight: z.number(), - }), - ), -}); - -export const UploadDocumentSuccessfulSchema = z.object({ - url: z.string(), - key: z.string(), -}); - -export const CreateDocumentMutationSchema = z.object({ - fileName: z.string(), - contentType: z.string().default('PDF'), -}); - -export const SuccessfulResponseSchema = z.object({ - documents: SuccessfulDocumentResponseSchema.array(), - totalPages: z.number(), -}); - -export const SuccessfulSigningResponseSchema = z.object({ - message: z.string(), -}); - -export const UnsuccessfulResponseSchema = z.object({ - message: z.string(), -}); - -export const AuthorizationHeadersSchema = z.object({ - authorization: z.string(), -}); diff --git a/packages/trpc/server/public-api/ts-rest.ts b/packages/trpc/server/public-api/ts-rest.ts deleted file mode 100644 index 0d66cda1f..000000000 --- a/packages/trpc/server/public-api/ts-rest.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createNextRoute, createNextRouter } from '@ts-rest/next'; - -export { createNextRoute, createNextRouter }; From 6e22eff5a14905a337fdf5b51aa2a0fe88708fb8 Mon Sep 17 00:00:00 2001 From: Gautam-Hegde Date: Tue, 23 Jan 2024 00:02:04 +0530 Subject: [PATCH 065/466] feat: command grp border --- packages/ui/primitives/command.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index cbc306c66..5f1ebe2e4 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -96,7 +96,10 @@ const CommandGroup = React.forwardRef< className, )} {...props} - /> + > +
+ {props.children} + )); CommandGroup.displayName = CommandPrimitive.Group.displayName; From e5c2263e9276ce08eabcab3d12e4d5dfd35ba19b Mon Sep 17 00:00:00 2001 From: Sumit Bisht Date: Tue, 23 Jan 2024 18:37:02 +0530 Subject: [PATCH 066/466] fix: imporoved document-dropzone ui for small vertical screens --- apps/web/src/app/(dashboard)/documents/upload-document.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index 65b95f9ec..71926dafc 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -96,10 +96,12 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { } }; + const isSmallVerticalScreen = typeof window !== 'undefined' && window.innerHeight < 800; + return (
Date: Thu, 25 Jan 2024 13:21:55 +0530 Subject: [PATCH 067/466] fixed undo operation on signature pad --- packages/ui/primitives/signature-pad/signature-pad.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index 80bac0e18..e6f844b52 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -26,7 +26,7 @@ export const SignaturePad = ({ ...props }: SignaturePadProps) => { const $el = useRef(null); - + const defaultImageRef = useRef(null); const [isPressed, setIsPressed] = useState(false); const [lines, setLines] = useState([]); const [currentLine, setCurrentLine] = useState([]); @@ -161,6 +161,7 @@ export const SignaturePad = ({ const ctx = $el.current.getContext('2d'); ctx?.clearRect(0, 0, $el.current.width, $el.current.height); + defaultImageRef.current = null; } onChange?.(null); @@ -181,8 +182,11 @@ export const SignaturePad = ({ // Clear the canvas if ($el.current) { const ctx = $el.current.getContext('2d'); + const { width, height } = $el.current; ctx?.clearRect(0, 0, $el.current.width, $el.current.height); - + if (typeof defaultValue === 'string' && defaultImageRef.current) { + ctx?.putImageData(defaultImageRef.current, 0, 0); + } newLines.forEach((line) => { const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); ctx?.fill(pathData); @@ -207,6 +211,8 @@ export const SignaturePad = ({ img.onload = () => { ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height)); + const defaultImageData = ctx?.getImageData(0, 0, width, height) || null; + defaultImageRef.current = defaultImageData; }; img.src = defaultValue; From 927a656c576228ab4ef54dbc5a22237a4a9c116b Mon Sep 17 00:00:00 2001 From: Tangerine Kugelmann <9637909+daallgeier@users.noreply.github.com> Date: Sun, 28 Jan 2024 01:00:07 +0100 Subject: [PATCH 068/466] Create security.txt See also https://securitytxt.org --- .well-known/security.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .well-known/security.txt diff --git a/.well-known/security.txt b/.well-known/security.txt new file mode 100644 index 000000000..5cd29187c --- /dev/null +++ b/.well-known/security.txt @@ -0,0 +1,3 @@ +Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml +Preferred-Languages: en +Canonical: https://documenso.com/.well-known/security.txt From 014c09bd910a407f974dff880e1c35e10e02ece8 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sun, 28 Jan 2024 18:43:20 +0000 Subject: [PATCH 069/466] fix: account deletion error for users without two factor authentication --- apps/web/src/components/forms/profile.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 575a81d46..80c33dd60 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -113,10 +113,26 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; - const onDeleteAccount = async () => { + const onDeleteAccount = async (hasTwoFactorAuthentication: boolean) => { try { + if (!hasTwoFactorAuthentication) { + await deleteAccount(); + + toast({ + title: 'Account deleted', + description: 'Your account has been deleted successfully.', + duration: 5000, + }); + + await signOut({ callbackUrl: '/' }); + + return; + } + const { token } = deleteAccountTwoFactorTokenForm.getValues(); + console.log(token); + if (!token) { throw new Error('Please enter your Two Factor Authentication token.'); } @@ -273,7 +289,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { + diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index df5132aff..13ab038d4 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -13,10 +13,32 @@ export const deleteUser = async ({ email }: DeleteUserOptions) => { }, }); + const defaultDeleteUser = await prisma.user.findFirst({ + where: { + email: 'deleted@documenso.com', + }, + }); + if (!user) { throw new Error(`User with email ${email} not found`); } + if (!defaultDeleteUser) { + throw new Error(`Default delete account not found`); + } + + await prisma.document.updateMany({ + where: { + userId: user.id, + status: { + in: ['PENDING', 'COMPLETED'], + }, + }, + data: { + userId: defaultDeleteUser.id, + }, + }); + return await prisma.user.delete({ where: { id: user.id, diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index cf5fdbf94..2cadfd574 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -139,10 +139,7 @@ export const profileRouter = router({ deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { try { const user = ctx.user; - - const deletedUser = await deleteStripeCustomer(user); - - console.log(deletedUser); + await deleteStripeCustomer(user); return await deleteUser(user); } catch (err) { From 30752815e77138c269ccc8f901575268e2df4f86 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Feb 2024 13:06:36 +0000 Subject: [PATCH 078/466] feat: soft-delete transfered documents --- packages/lib/server-only/user/delete-user.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 13ab038d4..352f5c9e9 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -36,6 +36,7 @@ export const deleteUser = async ({ email }: DeleteUserOptions) => { }, data: { userId: defaultDeleteUser.id, + deletedAt: new Date(), }, }); From edeeaa56518f9b9b5c5b4fe8f39bb64856e9558b Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 6 Feb 2024 16:00:28 +0200 Subject: [PATCH 079/466] feat: implement webhooks --- .../(dashboard)/settings/webhooks/page.tsx | 71 ++++++++ .../settings/layout/desktop-nav.tsx | 15 +- .../settings/layout/mobile-nav.tsx | 15 +- .../webhooks/create-webhook-dialog.tsx | 3 + .../webhooks/delete-webhook-dialog.tsx | 167 ++++++++++++++++++ apps/web/src/components/forms/webhook.tsx | 0 .../migration.sql | 19 ++ packages/prisma/schema.prisma | 18 ++ 8 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/webhooks/page.tsx create mode 100644 apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx create mode 100644 apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx create mode 100644 apps/web/src/components/forms/webhook.tsx create mode 100644 packages/prisma/migrations/20240206131417_add_user_webhooks/migration.sql diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx new file mode 100644 index 000000000..e7445c1d9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Zap } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog'; + +export default function WebhookPage() { + // TODO: Fetch webhooks from the DB after implementing the backend + const webhooks = [ + { + id: 1, + secret: 'my-secret', + webhookUrl: 'https://example.com/webhook', + eventTriggers: ['document.created', 'document.signed'], + enabled: true, + userID: 1, + }, + ]; + + return ( +
+ + + + + {webhooks.length === 0 && ( + // TODO: Perhaps add some illustrations here to make the page more engaging +
+

+ You have no webhooks yet. Your webhooks will be shown here once you create them. +

+
+ )} + + {webhooks.length > 0 && ( +
+ {webhooks.map((webhook) => ( +
+
+
+

Webhook URL

+

{webhook.webhookUrl}

+

Event triggers

+ {webhook.eventTriggers.map((trigger, index) => ( +

+ {trigger} +

+ ))} +
+
+
+ + + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index c7ab61d8a..5b7a1b739 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { CreditCard, Lock, User, Users } from 'lucide-react'; +import { CreditCard, Lock, User, Users, Webhook } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -48,6 +48,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + + + + + + + + + )} + + + + + Delete Webhook + + + Please note that this action is irreversible. Once confirmed, your webhook will be + permanently deleted. + + + +
+ +
+ ( + + + Confirm by typing:{' '} + + {deleteMessage} + + + + + + + + )} + /> + + +
+ + + +
+
+
+
+ +
+ + ); +}; diff --git a/apps/web/src/components/forms/webhook.tsx b/apps/web/src/components/forms/webhook.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/prisma/migrations/20240206131417_add_user_webhooks/migration.sql b/packages/prisma/migrations/20240206131417_add_user_webhooks/migration.sql new file mode 100644 index 000000000..7bf4e190f --- /dev/null +++ b/packages/prisma/migrations/20240206131417_add_user_webhooks/migration.sql @@ -0,0 +1,19 @@ +-- CreateEnum +CREATE TYPE "WebhookTriggerEvents" AS ENUM ('DOCUMENT_CREATED', 'DOCUMENT_SIGNED'); + +-- CreateTable +CREATE TABLE "Webhook" ( + "id" SERIAL NOT NULL, + "webhookUrl" TEXT NOT NULL, + "eventTriggers" "WebhookTriggerEvents"[], + "secret" TEXT, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" INTEGER NOT NULL, + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 79dcdf6aa..f2ce12cab 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -47,6 +47,7 @@ model User { VerificationToken VerificationToken[] Template Template[] securityAuditLogs UserSecurityAuditLog[] + Webhooks Webhook[] @@index([email]) } @@ -94,6 +95,23 @@ model VerificationToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +enum WebhookTriggerEvents { + DOCUMENT_CREATED + DOCUMENT_SIGNED +} + +model Webhook { + id Int @id @default(autoincrement()) + webhookUrl String + eventTriggers WebhookTriggerEvents[] + secret String? + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + userId Int + User User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + enum SubscriptionStatus { ACTIVE PAST_DUE From b3514bd0c7ada00debf38e6609ab18d23a5a62f5 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 7 Feb 2024 16:04:12 +0200 Subject: [PATCH 080/466] add new webhook dialog --- .../(dashboard)/settings/webhooks/page.tsx | 3 +- .../webhooks/create-webhook-dialog.tsx | 156 +++++++++++++++++- .../webhooks/multiselect-combobox.tsx | 83 ++++++++++ packages/tailwind-config/index.cjs | 3 + 4 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index e7445c1d9..9ca4b526e 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -5,6 +5,7 @@ import { Zap } from 'lucide-react'; import { Button } from '@documenso/ui/primitives/button'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog'; import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog'; export default function WebhookPage() { @@ -26,7 +27,7 @@ export default function WebhookPage() { title="Webhooks" subtitle="On this page, you can create new Webhooks and manage the existing ones." > - + {webhooks.length === 0 && ( diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 2924efc86..ec6d0f152 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -1,3 +1,157 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; + +import { Button } from '@documenso/ui/primitives/button'; +import { Input } from '@documenso/ui/primitives/input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; + +import { MultiSelectCombobox } from './multiselect-combobox'; + + +export type CreateWebhookDialogProps = { + trigger?: React.ReactNode; +} & Omit; + export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => { - return

test

; + const router = useRouter(); + const [open, setOpen] = useState(false); + + const form = useForm<>({ + resolver: zodResolver(), + values: { + webhookUrl: '', + eventTriggers: [], + secret: '', + enabled: true, + }, + }); + + const onSubmit = async () => { + console.log('submitted'); + } + + return ( + !form.formState.isSubmitting && setOpen(value)} + {...props} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Create webhook + On this page, you can create a new webhook. + + +
+ +
+ ( + + Webhook URL + + + + + + )} + /> + + ( + + Event triggers + + { + console.log(values); + onChange(values) + }} + /> + + + + )} + /> + + ( + + Secret + + + + + + )} + /> + + ( + + Active + + + + + + )} + /> + + +
+ + +
+
+ +
+
+ +
+
+ ); }; diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx new file mode 100644 index 000000000..269b83449 --- /dev/null +++ b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; + +import { WebhookTriggerEvents } from '@prisma/client/'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +type ComboboxProps = { + listValues: string[]; + onChange: (_values: string[]) => void; +}; + +const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { + const [isOpen, setIsOpen] = React.useState(false); + const [selectedValues, setSelectedValues] = React.useState([]); + + const triggerEvents = Object.values(WebhookTriggerEvents); + + React.useEffect(() => { + setSelectedValues(listValues); + }, [listValues]); + + const allEvents = [...new Set([...triggerEvents, ...selectedValues])]; + + const handleSelect = (currentValue: string) => { + let newSelectedValues; + if (selectedValues.includes(currentValue)) { + newSelectedValues = selectedValues.filter((value) => value !== currentValue); + } else { + newSelectedValues = [...selectedValues, currentValue]; + } + + setSelectedValues(newSelectedValues); + onChange(newSelectedValues); + setIsOpen(false); + }; + + return ( + + + + + + + + No value found. + + {allEvents.map((value: string, i: number) => ( + handleSelect(value)}> + + {value} + + ))} + + + + + ); +}; + +export { MultiSelectCombobox }; diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs index 1564454d8..6dfa7d5c2 100644 --- a/packages/tailwind-config/index.cjs +++ b/packages/tailwind-config/index.cjs @@ -11,6 +11,9 @@ module.exports = { sans: ['var(--font-sans)', ...fontFamily.sans], signature: ['var(--font-signature)'], }, + zIndex: { + 9999: '9999', + }, colors: { border: 'hsl(var(--border))', input: 'hsl(var(--input))', From 98df273ebc62d2c98508da4965dba04e0f9ed0c8 Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 29 Jan 2024 22:53:15 +1100 Subject: [PATCH 081/466] feat: add field and recipient endpoints --- packages/api/v1/contract.ts | 76 ++++ packages/api/v1/implementation.ts | 352 +++++++++++++++++- packages/api/v1/schema.ts | 69 +++- .../lib/server-only/field/create-field.ts | 41 ++ .../lib/server-only/field/delete-field.ts | 17 + .../lib/server-only/field/get-field-by-id.ts | 17 + .../lib/server-only/field/update-field.ts | 44 +++ .../server-only/recipient/delete-recipient.ts | 32 ++ .../recipient/get-recipient-by-email.ts | 21 ++ .../recipient/get-recipient-by-id.ts | 21 ++ .../server-only/recipient/update-recipient.ts | 38 ++ 11 files changed, 720 insertions(+), 8 deletions(-) create mode 100644 packages/lib/server-only/field/create-field.ts create mode 100644 packages/lib/server-only/field/delete-field.ts create mode 100644 packages/lib/server-only/field/get-field-by-id.ts create mode 100644 packages/lib/server-only/field/update-field.ts create mode 100644 packages/lib/server-only/recipient/delete-recipient.ts create mode 100644 packages/lib/server-only/recipient/get-recipient-by-email.ts create mode 100644 packages/lib/server-only/recipient/get-recipient-by-id.ts create mode 100644 packages/lib/server-only/recipient/update-recipient.ts diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index 438fa9cee..a7dd27f7c 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -4,14 +4,20 @@ import { ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema, ZAuthorizationHeadersSchema, ZCreateDocumentMutationSchema, + ZCreateFieldMutationSchema, ZCreateRecipientMutationSchema, ZDeleteDocumentMutationSchema, + ZDeleteFieldMutationSchema, + ZDeleteRecipientMutationSchema, ZGetDocumentsQuerySchema, ZSuccessfulDocumentResponseSchema, + ZSuccessfulFieldResponseSchema, ZSuccessfulRecipientResponseSchema, ZSuccessfulResponseSchema, ZSuccessfulSigningResponseSchema, ZUnsuccessfulResponseSchema, + ZUpdateFieldMutationSchema, + ZUpdateRecipientMutationSchema, ZUploadDocumentSuccessfulSchema, } from './schema'; @@ -93,6 +99,76 @@ export const ApiContractV1 = c.router( }, summary: 'Create a recipient for a document', }, + + updateRecipient: { + method: 'PATCH', + path: '/api/v1/documents/:id/recipients/:recipientId', + body: ZUpdateRecipientMutationSchema, + responses: { + 200: ZSuccessfulRecipientResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Update a recipient for a document', + }, + + deleteRecipient: { + method: 'DELETE', + path: '/api/v1/documents/:id/recipients/:recipientId', + body: ZDeleteRecipientMutationSchema, + responses: { + 200: ZSuccessfulRecipientResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Delete a recipient from a document', + }, + + createField: { + method: 'POST', + path: '/api/v1/documents/:id/fields', + body: ZCreateFieldMutationSchema, + responses: { + 200: ZSuccessfulFieldResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Create a field for a document', + }, + + updateField: { + method: 'PATCH', + path: '/api/v1/documents/:id/fields/:fieldId', + body: ZUpdateFieldMutationSchema, + responses: { + 200: ZSuccessfulFieldResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Update a field for a document', + }, + + deleteField: { + method: 'DELETE', + path: '/api/v1/documents/:id/fields/:fieldId', + body: ZDeleteFieldMutationSchema, + responses: { + 200: ZSuccessfulFieldResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Delete a field from a document', + }, }, { baseHeaders: ZAuthorizationHeadersSchema, diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 4dd709246..e9b710c46 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -4,10 +4,17 @@ import { deleteDocument } from '@documenso/lib/server-only/document/delete-docum import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; +import { createField } from '@documenso/lib/server-only/field/create-field'; +import { deleteField } from '@documenso/lib/server-only/field/delete-field'; +import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id'; +import { updateField } from '@documenso/lib/server-only/field/update-field'; +import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient'; +import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { ApiContractV1 } from './contract'; import { authenticatedMiddleware } from './middleware/authenticated'; @@ -250,4 +257,347 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; } }), + + updateRecipient: authenticatedMiddleware(async (args, user) => { + const { id: documentId, recipientId } = args.params; + const { name, email } = args.body; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already completed', + }, + }; + } + + const updatedRecipient = await updateRecipient({ + documentId: Number(documentId), + recipientId: Number(recipientId), + email, + name, + }).catch(() => null); + + if (!updatedRecipient) { + return { + status: 404, + body: { + message: 'Recipient not found', + }, + }; + } + + return { + status: 200, + body: updatedRecipient, + }; + }), + + deleteRecipient: authenticatedMiddleware(async (args, user) => { + const { id: documentId, recipientId } = args.params; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already completed', + }, + }; + } + + const deletedRecipient = await deleteRecipient({ + documentId: Number(documentId), + recipientId: Number(recipientId), + }).catch(() => null); + + if (!deletedRecipient) { + return { + status: 400, + body: { + message: 'Unable to delete recipient', + }, + }; + } + + return { + status: 200, + body: deletedRecipient, + }; + }), + + createField: authenticatedMiddleware(async (args, user) => { + const { id: documentId } = args.params; + const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already completed', + }, + }; + } + + const recipient = await getRecipientById({ + id: Number(recipientId), + documentId: Number(documentId), + }).catch(() => null); + + if (!recipient) { + return { + status: 404, + body: { + message: 'Recipient not found', + }, + }; + } + + if (recipient.signingStatus === SigningStatus.SIGNED) { + return { + status: 400, + body: { + message: 'Recipient has already signed the document', + }, + }; + } + + const field = await createField({ + documentId: Number(documentId), + recipientId: Number(recipientId), + type, + pageNumber, + pageX, + pageY, + pageWidth, + pageHeight, + }); + + const remappedField = { + documentId: field.documentId, + recipientId: field.recipientId ?? -1, + type: field.type, + pageNumber: field.page, + pageX: Number(field.positionX), + pageY: Number(field.positionY), + pageWidth: Number(field.width), + pageHeight: Number(field.height), + customText: field.customText, + inserted: field.inserted, + }; + + return { + status: 200, + body: remappedField, + }; + }), + + updateField: authenticatedMiddleware(async (args, user) => { + const { id: documentId, fieldId } = args.params; + const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already completed', + }, + }; + } + + const recipient = await getRecipientById({ + id: Number(recipientId), + documentId: Number(documentId), + }).catch(() => null); + + if (!recipient) { + return { + status: 404, + body: { + message: 'Recipient not found', + }, + }; + } + + if (recipient.signingStatus === SigningStatus.SIGNED) { + return { + status: 400, + body: { + message: 'Recipient has already signed the document', + }, + }; + } + + const updatedField = await updateField({ + fieldId: Number(fieldId), + documentId: Number(documentId), + recipientId: recipientId ? Number(recipientId) : undefined, + type, + pageNumber, + pageX, + pageY, + pageWidth, + pageHeight, + }); + + const remappedField = { + documentId: updatedField.documentId, + recipientId: updatedField.recipientId ?? -1, + type: updatedField.type, + pageNumber: updatedField.page, + pageX: Number(updatedField.positionX), + pageY: Number(updatedField.positionY), + pageWidth: Number(updatedField.width), + pageHeight: Number(updatedField.height), + customText: updatedField.customText, + inserted: updatedField.inserted, + }; + + return { + status: 200, + body: remappedField, + }; + }), + + deleteField: authenticatedMiddleware(async (args, user) => { + const { id: documentId, fieldId } = args.params; + + const document = await getDocumentById({ + id: Number(documentId), + userId: user.id, + }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === DocumentStatus.COMPLETED) { + return { + status: 400, + body: { + message: 'Document is already completed', + }, + }; + } + + const field = await getFieldById({ + fieldId: Number(fieldId), + documentId: Number(documentId), + }).catch(() => null); + + if (!field) { + return { + status: 404, + body: { + message: 'Field not found', + }, + }; + } + + const recipient = await getRecipientById({ + id: Number(field.recipientId), + documentId: Number(documentId), + }).catch(() => null); + + if (recipient?.signingStatus === SigningStatus.SIGNED) { + return { + status: 400, + body: { + message: 'Recipient has already signed the document', + }, + }; + } + + const deletedField = await deleteField({ + documentId: Number(documentId), + fieldId: Number(fieldId), + }).catch(() => null); + + if (!deletedField) { + return { + status: 400, + body: { + message: 'Unable to delete field', + }, + }; + } + + const remappedField = { + documentId: deletedField.documentId, + recipientId: deletedField.recipientId ?? -1, + type: deletedField.type, + pageNumber: deletedField.page, + pageX: Number(deletedField.positionX), + pageY: Number(deletedField.positionY), + pageWidth: Number(deletedField.width), + pageHeight: Number(deletedField.height), + customText: deletedField.customText, + inserted: deletedField.inserted, + }; + + return { + status: 200, + body: remappedField, + }; + }), }); diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 91c35ce28..f6fba2f0f 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; -import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { FieldType, ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; +/** + * Documents + */ export const ZGetDocumentsQuerySchema = z.object({ page: z.string().optional(), perPage: z.string().optional(), @@ -49,8 +52,19 @@ export const ZCreateRecipientMutationSchema = z.object({ email: z.string().email().min(1), }); +/** + * Recipients + */ export type TCreateRecipientMutationSchema = z.infer; +export const ZUpdateRecipientMutationSchema = ZCreateRecipientMutationSchema.partial(); + +export type TUpdateRecipientMutationSchema = z.infer; + +export const ZDeleteRecipientMutationSchema = null; + +export type TDeleteRecipientMutationSchema = typeof ZDeleteRecipientMutationSchema; + export const ZSuccessfulRecipientResponseSchema = z.object({ id: z.number(), documentId: z.number(), @@ -67,6 +81,44 @@ export const ZSuccessfulRecipientResponseSchema = z.object({ export type TSuccessfulRecipientResponseSchema = z.infer; +/** + * Fields + */ +export const ZCreateFieldMutationSchema = z.object({ + recipientId: z.number(), + type: z.nativeEnum(FieldType), + pageNumber: z.number(), + pageX: z.number(), + pageY: z.number(), + pageWidth: z.number(), + pageHeight: z.number(), +}); + +export type TCreateFieldMutationSchema = z.infer; + +export const ZUpdateFieldMutationSchema = ZCreateFieldMutationSchema.partial(); + +export type TUpdateFieldMutationSchema = z.infer; + +export const ZDeleteFieldMutationSchema = null; + +export type TDeleteFieldMutationSchema = typeof ZDeleteFieldMutationSchema; + +export const ZSuccessfulFieldResponseSchema = z.object({ + documentId: z.number(), + recipientId: z.number(), + type: z.nativeEnum(FieldType), + pageNumber: z.number(), + pageX: z.number(), + pageY: z.number(), + pageWidth: z.number(), + pageHeight: z.number(), + customText: z.string(), + inserted: z.boolean(), +}); + +export type TSuccessfulFieldResponseSchema = z.infer; + export const ZSuccessfulResponseSchema = z.object({ documents: ZSuccessfulDocumentResponseSchema.array(), totalPages: z.number(), @@ -80,14 +132,17 @@ export const ZSuccessfulSigningResponseSchema = z.object({ export type TSuccessfulSigningResponseSchema = z.infer; -export const ZUnsuccessfulResponseSchema = z.object({ - message: z.string(), -}); - -export type TUnsuccessfulResponseSchema = z.infer; - +/** + * General + */ export const ZAuthorizationHeadersSchema = z.object({ authorization: z.string(), }); export type TAuthorizationHeadersSchema = z.infer; + +export const ZUnsuccessfulResponseSchema = z.object({ + message: z.string(), +}); + +export type TUnsuccessfulResponseSchema = z.infer; diff --git a/packages/lib/server-only/field/create-field.ts b/packages/lib/server-only/field/create-field.ts new file mode 100644 index 000000000..c61e36340 --- /dev/null +++ b/packages/lib/server-only/field/create-field.ts @@ -0,0 +1,41 @@ +import { prisma } from '@documenso/prisma'; +import type { FieldType } from '@documenso/prisma/client'; + +export type CreateFieldOptions = { + documentId: number; + recipientId: number; + type: FieldType; + pageNumber: number; + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; +}; + +export const createField = async ({ + documentId, + recipientId, + type, + pageNumber, + pageX, + pageY, + pageWidth, + pageHeight, +}: CreateFieldOptions) => { + const field = await prisma.field.create({ + data: { + documentId, + recipientId, + type, + page: pageNumber, + positionX: pageX, + positionY: pageY, + width: pageWidth, + height: pageHeight, + customText: '', + inserted: false, + }, + }); + + return field; +}; diff --git a/packages/lib/server-only/field/delete-field.ts b/packages/lib/server-only/field/delete-field.ts new file mode 100644 index 000000000..d775c84bd --- /dev/null +++ b/packages/lib/server-only/field/delete-field.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; + +export type DeleteFieldOptions = { + fieldId: number; + documentId: number; +}; + +export const deleteField = async ({ fieldId, documentId }: DeleteFieldOptions) => { + const field = await prisma.field.delete({ + where: { + id: fieldId, + documentId, + }, + }); + + return field; +}; diff --git a/packages/lib/server-only/field/get-field-by-id.ts b/packages/lib/server-only/field/get-field-by-id.ts new file mode 100644 index 000000000..0e0f9b2dd --- /dev/null +++ b/packages/lib/server-only/field/get-field-by-id.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; + +export type GetFieldByIdOptions = { + fieldId: number; + documentId: number; +}; + +export const getFieldById = async ({ fieldId, documentId }: GetFieldByIdOptions) => { + const field = await prisma.field.findFirst({ + where: { + id: fieldId, + documentId, + }, + }); + + return field; +}; diff --git a/packages/lib/server-only/field/update-field.ts b/packages/lib/server-only/field/update-field.ts new file mode 100644 index 000000000..4d949a8cb --- /dev/null +++ b/packages/lib/server-only/field/update-field.ts @@ -0,0 +1,44 @@ +import { prisma } from '@documenso/prisma'; +import type { FieldType } from '@documenso/prisma/client'; + +export type UpdateFieldOptions = { + fieldId: number; + documentId: number; + recipientId?: number; + type?: FieldType; + pageNumber?: number; + pageX?: number; + pageY?: number; + pageWidth?: number; + pageHeight?: number; +}; + +export const updateField = async ({ + fieldId, + documentId, + recipientId, + type, + pageNumber, + pageX, + pageY, + pageWidth, + pageHeight, +}: UpdateFieldOptions) => { + const field = await prisma.field.update({ + where: { + id: fieldId, + documentId, + }, + data: { + recipientId, + type, + page: pageNumber, + positionX: pageX, + positionY: pageY, + width: pageWidth, + height: pageHeight, + }, + }); + + return field; +}; diff --git a/packages/lib/server-only/recipient/delete-recipient.ts b/packages/lib/server-only/recipient/delete-recipient.ts new file mode 100644 index 000000000..67b948f6a --- /dev/null +++ b/packages/lib/server-only/recipient/delete-recipient.ts @@ -0,0 +1,32 @@ +import { prisma } from '@documenso/prisma'; +import { SendStatus } from '@documenso/prisma/client'; + +export type DeleteRecipientOptions = { + documentId: number; + recipientId: number; +}; + +export const deleteRecipient = async ({ documentId, recipientId }: DeleteRecipientOptions) => { + const recipient = await prisma.recipient.findFirst({ + where: { + id: recipientId, + documentId, + }, + }); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + if (recipient.sendStatus !== SendStatus.NOT_SENT) { + throw new Error('Can not delete a recipient that has already been sent a document'); + } + + const deletedRecipient = await prisma.recipient.delete({ + where: { + id: recipient.id, + }, + }); + + return deletedRecipient; +}; diff --git a/packages/lib/server-only/recipient/get-recipient-by-email.ts b/packages/lib/server-only/recipient/get-recipient-by-email.ts new file mode 100644 index 000000000..349149105 --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipient-by-email.ts @@ -0,0 +1,21 @@ +import { prisma } from '@documenso/prisma'; + +export type GetRecipientByEmailOptions = { + documentId: number; + email: string; +}; + +export const getRecipientByEmail = async ({ documentId, email }: GetRecipientByEmailOptions) => { + const recipient = await prisma.recipient.findFirst({ + where: { + documentId, + email: email.toLowerCase(), + }, + }); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + return recipient; +}; diff --git a/packages/lib/server-only/recipient/get-recipient-by-id.ts b/packages/lib/server-only/recipient/get-recipient-by-id.ts new file mode 100644 index 000000000..0db306b80 --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipient-by-id.ts @@ -0,0 +1,21 @@ +import { prisma } from '@documenso/prisma'; + +export type GetRecipientByIdOptions = { + id: number; + documentId: number; +}; + +export const getRecipientById = async ({ documentId, id }: GetRecipientByIdOptions) => { + const recipient = await prisma.recipient.findFirst({ + where: { + documentId, + id, + }, + }); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + return recipient; +}; diff --git a/packages/lib/server-only/recipient/update-recipient.ts b/packages/lib/server-only/recipient/update-recipient.ts new file mode 100644 index 000000000..0b1fa046d --- /dev/null +++ b/packages/lib/server-only/recipient/update-recipient.ts @@ -0,0 +1,38 @@ +import { prisma } from '@documenso/prisma'; + +export type UpdateRecipientOptions = { + documentId: number; + recipientId: number; + email?: string; + name?: string; +}; + +export const updateRecipient = async ({ + documentId, + recipientId, + email, + name, +}: UpdateRecipientOptions) => { + const recipient = await prisma.recipient.findFirst({ + where: { + id: recipientId, + documentId, + }, + }); + + if (!recipient) { + throw new Error('Recipient not found'); + } + + const updatedRecipient = await prisma.recipient.update({ + where: { + id: recipient.id, + }, + data: { + email: email?.toLowerCase() ?? recipient.email, + name: name ?? recipient.name, + }, + }); + + return updatedRecipient; +}; From 58f4b729398d5a9f21769c8857ae445fe21bf8a4 Mon Sep 17 00:00:00 2001 From: apoorv taneja Date: Thu, 8 Feb 2024 13:31:38 +0530 Subject: [PATCH 082/466] added fixed width for status col --- apps/web/src/app/(dashboard)/documents/data-table.tsx | 1 + packages/ui/primitives/data-table.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index c8adb1422..a1bc76b1d 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -72,6 +72,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { header: 'Status', accessorKey: 'status', cell: ({ row }) => , + size: 140, }, { header: 'Actions', diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx index 9cc14a684..55895e08f 100644 --- a/packages/ui/primitives/data-table.tsx +++ b/packages/ui/primitives/data-table.tsx @@ -115,7 +115,12 @@ export function DataTable({ table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} From b3ba77dfed53cb9ecf4b9393635d558c18c602ed Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 9 Feb 2024 11:32:54 +0200 Subject: [PATCH 083/466] feat: allow user to choose expiry date --- .../app/(dashboard)/settings/tokens/page.tsx | 12 +- .../(dashboard)/settings/token/contants.ts | 7 ++ apps/web/src/components/forms/token.tsx | 108 +++++++++++++++--- packages/lib/constants/time.ts | 8 +- .../public-api/create-api-token.ts | 22 +++- .../migration.sql | 2 + packages/prisma/schema.prisma | 2 +- .../trpc/server/api-token-router/router.ts | 4 +- .../trpc/server/api-token-router/schema.ts | 1 + 9 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/components/(dashboard)/settings/token/contants.ts create mode 100644 packages/prisma/migrations/20240208135802_make_expiry_date_optional_api_tokens/migration.sql diff --git a/apps/web/src/app/(dashboard)/settings/tokens/page.tsx b/apps/web/src/app/(dashboard)/settings/tokens/page.tsx index 86143c633..8951098c4 100644 --- a/apps/web/src/app/(dashboard)/settings/tokens/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/tokens/page.tsx @@ -48,9 +48,15 @@ export default async function ApiTokensPage() {

Created on

-

- Expires on -

+ {token.expires ? ( +

+ Expires on +

+ ) : ( +

+ Token doesn't have an expiration date +

+ )}
diff --git a/apps/web/src/components/(dashboard)/settings/token/contants.ts b/apps/web/src/components/(dashboard)/settings/token/contants.ts new file mode 100644 index 000000000..232c37644 --- /dev/null +++ b/apps/web/src/components/(dashboard)/settings/token/contants.ts @@ -0,0 +1,7 @@ +export const EXPIRATION_DATES = { + ONE_WEEK: '7 days', + ONE_MONTH: '1 month', + THREE_MONTHS: '3 months', + SIX_MONTHS: '6 months', + ONE_YEAR: '12 months', +} as const; diff --git a/apps/web/src/components/forms/token.tsx b/apps/web/src/components/forms/token.tsx index 97e1c17ad..688ff47ca 100644 --- a/apps/web/src/components/forms/token.tsx +++ b/apps/web/src/components/forms/token.tsx @@ -1,12 +1,12 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import type { z } from 'zod'; +import { z } from 'zod'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { TRPCClientError } from '@documenso/trpc/client'; @@ -26,9 +26,21 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { Switch } from '@documenso/ui/primitives/switch'; import { useToast } from '@documenso/ui/primitives/use-toast'; -const ZCreateTokenFormSchema = ZCreateTokenMutationSchema; +import { EXPIRATION_DATES } from '../(dashboard)/settings/token/contants'; + +const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({ + enabled: z.boolean(), +}); type TCreateTokenFormSchema = z.infer; @@ -43,6 +55,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { const { toast } = useToast(); const [newlyCreatedToken, setNewlyCreatedToken] = useState(''); + const [noExpirationDate, setNoExpirationDate] = useState(false); const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({ onSuccess(data) { @@ -54,9 +67,21 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { resolver: zodResolver(ZCreateTokenFormSchema), defaultValues: { tokenName: '', + expirationDate: '', + enabled: false, }, }); + useEffect(() => { + if (newlyCreatedToken) { + const timer = setTimeout(() => { + setNewlyCreatedToken(''); + }, 30000); + + return () => clearTimeout(timer); + } + }, [newlyCreatedToken]); + const copyToken = async (token: string) => { try { const copied = await copy(token); @@ -78,10 +103,11 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { } }; - const onSubmit = async ({ tokenName }: TCreateTokenMutationSchema) => { + const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => { try { await createTokenMutation({ tokenName, + expirationDate: noExpirationDate ? null : expirationDate, }); toast({ @@ -116,30 +142,21 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
-
+
( - Token Name + Token name
- -
- + Please enter a meaningful name for your token. This will help you identify it later. @@ -149,6 +166,65 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { )} /> + ( + + Token expiration date + +
+ + + +
+ + +
+ )} + /> + + ( + + Never expire + + { + setNoExpirationDate((prev) => !prev); + field.onChange(val); + }} + /> + + + + )} + /> + + +
-
diff --git a/packages/lib/server-only/webhooks/create-webhook.ts b/packages/lib/server-only/webhooks/create-webhook.ts new file mode 100644 index 000000000..ee352c49f --- /dev/null +++ b/packages/lib/server-only/webhooks/create-webhook.ts @@ -0,0 +1,28 @@ +import { prisma } from '@documenso/prisma'; +import type { WebhookTriggerEvents } from '@documenso/prisma/client'; + +export interface CreateWebhookOptions { + webhookUrl: string; + eventTriggers: WebhookTriggerEvents[]; + secret: string | null; + enabled: boolean; + userId: number; +} + +export const createWebhook = async ({ + webhookUrl, + eventTriggers, + secret, + enabled, + userId, +}: CreateWebhookOptions) => { + return await prisma.webhook.create({ + data: { + webhookUrl, + eventTriggers, + secret, + enabled, + userId, + }, + }); +}; diff --git a/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts new file mode 100644 index 000000000..a775ac30c --- /dev/null +++ b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts @@ -0,0 +1,9 @@ +import { prisma } from '@documenso/prisma'; + +export const getWebhooksByUserId = async (userId: number) => { + return await prisma.webhook.findMany({ + where: { + userId, + }, + }); +}; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index aec70fd63..571d43669 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -11,6 +11,7 @@ import { teamRouter } from './team-router/router'; import { templateRouter } from './template-router/router'; import { router } from './trpc'; import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; +import { webhookRouter } from './webhook-router/router'; export const appRouter = router({ auth: authRouter, @@ -24,6 +25,7 @@ export const appRouter = router({ singleplayer: singleplayerRouter, team: teamRouter, template: templateRouter, + webhook: webhookRouter, twoFactorAuthentication: twoFactorAuthenticationRouter, }); diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts new file mode 100644 index 000000000..ffdd7c4bf --- /dev/null +++ b/packages/trpc/server/webhook-router/router.ts @@ -0,0 +1,35 @@ +import { TRPCError } from '@trpc/server'; + +import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook'; +import { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id'; + +import { authenticatedProcedure, router } from '../trpc'; +import { ZCreateWebhookFormSchema } from './schema'; + +export const webhookRouter = router({ + getWebhooks: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getWebhooksByUserId(ctx.user.id); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to fetch your webhooks. Please try again later.', + }); + } + }), + createWebhook: authenticatedProcedure + .input(ZCreateWebhookFormSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createWebhook({ + ...input, + userId: ctx.user.id, + }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this webhook. Please try again later.', + }); + } + }), +}); diff --git a/packages/trpc/server/webhook-router/schema.ts b/packages/trpc/server/webhook-router/schema.ts new file mode 100644 index 000000000..feceac054 --- /dev/null +++ b/packages/trpc/server/webhook-router/schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { WebhookTriggerEvents } from '@documenso/prisma/client'; + +export const ZCreateWebhookFormSchema = z.object({ + webhookUrl: z.string().url(), + eventTriggers: z + .array(z.nativeEnum(WebhookTriggerEvents)) + .min(1, { message: 'At least one event trigger is required' }), + secret: z.string().nullable(), + enabled: z.boolean(), +}); + +export type TCreateWebhookFormSchema = z.infer; From 0209127136556074cc026fb621b513b1c258e186 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 9 Feb 2024 16:28:18 +0200 Subject: [PATCH 085/466] feat: delete webhook functionality --- .../(dashboard)/settings/webhooks/page.tsx | 29 ++++++++++--------- .../webhooks/create-webhook-dialog.tsx | 1 - .../webhooks/delete-webhook-dialog.tsx | 2 +- .../webhooks/delete-webhook-by-id.ts | 15 ++++++++++ packages/trpc/server/webhook-router/router.ts | 19 ++++++++++++ packages/trpc/server/webhook-router/schema.ts | 6 ++++ 6 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 packages/lib/server-only/webhooks/delete-webhook-by-id.ts diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index 9ca4b526e..060257d72 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -1,7 +1,9 @@ 'use client'; import { Zap } from 'lucide-react'; +import { ToggleLeft, ToggleRight } from 'lucide-react'; +import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; @@ -9,17 +11,7 @@ import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/ import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog'; export default function WebhookPage() { - // TODO: Fetch webhooks from the DB after implementing the backend - const webhooks = [ - { - id: 1, - secret: 'my-secret', - webhookUrl: 'https://example.com/webhook', - eventTriggers: ['document.created', 'document.signed'], - enabled: true, - userID: 1, - }, - ]; + const { data: webhooks } = trpc.webhook.getWebhooks.useQuery(); return (
@@ -30,7 +22,7 @@ export default function WebhookPage() { - {webhooks.length === 0 && ( + {webhooks?.length === 0 && ( // TODO: Perhaps add some illustrations here to make the page more engaging

@@ -39,9 +31,9 @@ export default function WebhookPage() {

)} - {webhooks.length > 0 && ( + {webhooks?.length > 0 && (
- {webhooks.map((webhook) => ( + {webhooks?.map((webhook) => (
@@ -53,6 +45,15 @@ export default function WebhookPage() { {trigger}

))} + {webhook.enabled ? ( +

+ Active +

+ ) : ( +

+ Inactive +

+ )}
diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 7d4003fb5..0e24b04a7 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -127,7 +127,6 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr { - console.log(values); onChange(values); }} /> diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx index 540bcf657..8f4a4008f 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx @@ -52,7 +52,7 @@ export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogPr type TDeleteWebhookFormSchema = z.infer; - const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhookById.useMutation(); + const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation(); const form = useForm({ resolver: zodResolver(ZDeleteWebhookFormSchema), diff --git a/packages/lib/server-only/webhooks/delete-webhook-by-id.ts b/packages/lib/server-only/webhooks/delete-webhook-by-id.ts new file mode 100644 index 000000000..306d0ca9c --- /dev/null +++ b/packages/lib/server-only/webhooks/delete-webhook-by-id.ts @@ -0,0 +1,15 @@ +import { prisma } from '@documenso/prisma'; + +export type DeleteWebhookByIdOptions = { + id: number; + userId: number; +}; + +export const deleteWebhookById = async ({ id, userId }: DeleteWebhookByIdOptions) => { + return await prisma.webhook.delete({ + where: { + id, + userId, + }, + }); +}; diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index ffdd7c4bf..6598b856a 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -1,10 +1,12 @@ import { TRPCError } from '@trpc/server'; import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook'; +import { deleteWebhookById } from '@documenso/lib/server-only/webhooks/delete-webhook-by-id'; import { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id'; import { authenticatedProcedure, router } from '../trpc'; import { ZCreateWebhookFormSchema } from './schema'; +import { ZDeleteWebhookSchema } from './schema'; export const webhookRouter = router({ getWebhooks: authenticatedProcedure.query(async ({ ctx }) => { @@ -32,4 +34,21 @@ export const webhookRouter = router({ }); } }), + deleteWebhook: authenticatedProcedure + .input(ZDeleteWebhookSchema) + .mutation(async ({ input, ctx }) => { + try { + const { id } = input; + + return await deleteWebhookById({ + id, + userId: ctx.user.id, + }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this webhook. Please try again later.', + }); + } + }), }); diff --git a/packages/trpc/server/webhook-router/schema.ts b/packages/trpc/server/webhook-router/schema.ts index feceac054..aba409c2f 100644 --- a/packages/trpc/server/webhook-router/schema.ts +++ b/packages/trpc/server/webhook-router/schema.ts @@ -11,4 +11,10 @@ export const ZCreateWebhookFormSchema = z.object({ enabled: z.boolean(), }); +export const ZDeleteWebhookSchema = z.object({ + id: z.number(), +}); + export type TCreateWebhookFormSchema = z.infer; + +export type TDeleteWebhookSchema = z.infer; From 1a82740d0f8e97e08e88d538867dd444fe86ab6c Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 12 Feb 2024 15:16:09 +1100 Subject: [PATCH 086/466] feat: support recipient roles --- package-lock.json | 97 +------------------ packages/api/v1/contract.ts | 4 +- packages/api/v1/implementation.ts | 81 +++++++++++++--- packages/api/v1/schema.ts | 43 +++++++- .../server-only/recipient/update-recipient.ts | 4 + 5 files changed, 119 insertions(+), 110 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5194b44e2..046b294b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7415,16 +7415,16 @@ "crypto-js": "^4.2.0" } }, - "node_modules/@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" - }, "node_modules/@vvo/tzdb": { "version": "6.117.0", "resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.117.0.tgz", "integrity": "sha512-vZkfoag1kHqItK/zebxT0Fkt3R/zscjgD+Ib7kaAdum0Sz9psXDfVHPW1Benv91d02zPWlLIvZtjBmzX4a+6fw==" }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -15182,17 +15182,6 @@ "tslib": "^2.0.3" } }, - "node_modules/node-abi": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.51.0.tgz", - "integrity": "sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -18264,82 +18253,6 @@ "sha.js": "bin.js" } }, - "node_modules/sharp": { - "version": "0.32.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.5.tgz", - "integrity": "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ==", - "hasInstallScript": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/sharp/node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sharp/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sharp/node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" - }, - "node_modules/sharp/node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index a7dd27f7c..b0796815c 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -3,6 +3,7 @@ import { initContract } from '@ts-rest/core'; import { ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema, ZAuthorizationHeadersSchema, + ZCreateDocumentMutationResponseSchema, ZCreateDocumentMutationSchema, ZCreateFieldMutationSchema, ZCreateRecipientMutationSchema, @@ -18,7 +19,6 @@ import { ZUnsuccessfulResponseSchema, ZUpdateFieldMutationSchema, ZUpdateRecipientMutationSchema, - ZUploadDocumentSuccessfulSchema, } from './schema'; const c = initContract(); @@ -53,7 +53,7 @@ export const ApiContractV1 = c.router( path: '/api/v1/documents', body: ZCreateDocumentMutationSchema, responses: { - 200: ZUploadDocumentSuccessfulSchema, + 200: ZCreateDocumentMutationResponseSchema, 401: ZUnsuccessfulResponseSchema, 404: ZUnsuccessfulResponseSchema, }, diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index e9b710c46..4e4af848d 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -1,5 +1,7 @@ import { createNextRoute } from '@ts-rest/next'; +import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; +import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; @@ -14,7 +16,7 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { ApiContractV1 } from './contract'; import { authenticatedMiddleware } from './middleware/authenticated'; @@ -81,17 +83,50 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } }), - createDocument: authenticatedMiddleware(async (args, _user) => { + createDocument: authenticatedMiddleware(async (args, user) => { const { body } = args; try { - const { url, key } = await getPresignPostUrl(body.fileName, body.contentType); + if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') { + return { + status: 500, + body: { + message: 'Create document is not available without S3 transport.', + }, + }; + } + + const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`; + + const { url, key } = await getPresignPostUrl(fileName, 'application/pdf'); + + const documentData = await createDocumentData({ + data: key, + type: DocumentDataType.S3_PATH, + }); + + const document = await createDocument({ + title: body.title, + userId: user.id, + documentDataId: documentData.id, + }); + + const recipients = await setRecipientsForDocument({ + userId: user.id, + documentId: document.id, + recipients: body.recipients, + }); return { status: 200, body: { - url, - key, + uploadUrl: url, + documentId: document.id, + recipients: recipients.map((recipient) => ({ + recipientId: recipient.id, + token: recipient.token, + role: recipient.role, + })), }, }; } catch (err) { @@ -184,7 +219,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { createRecipient: authenticatedMiddleware(async (args, user) => { const { id: documentId } = args.params; - const { name, email } = args.body; + const { name, email, role } = args.body; const document = await getDocumentById({ id: Number(documentId), @@ -234,6 +269,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { { email, name, + role, }, ], }); @@ -246,7 +282,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { return { status: 200, - body: newRecipient, + body: { + ...newRecipient, + documentId: Number(documentId), + }, }; } catch (err) { return { @@ -260,7 +299,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { updateRecipient: authenticatedMiddleware(async (args, user) => { const { id: documentId, recipientId } = args.params; - const { name, email } = args.body; + const { name, email, role } = args.body; const document = await getDocumentById({ id: Number(documentId), @@ -290,6 +329,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { recipientId: Number(recipientId), email, name, + role, }).catch(() => null); if (!updatedRecipient) { @@ -303,7 +343,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { return { status: 200, - body: updatedRecipient, + body: { + ...updatedRecipient, + documentId: Number(documentId), + }, }; }), @@ -349,7 +392,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { return { status: 200, - body: deletedRecipient, + body: { + ...deletedRecipient, + documentId: Number(documentId), + }, }; }), @@ -429,7 +475,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { return { status: 200, - body: remappedField, + body: { + ...remappedField, + documentId: Number(documentId), + }, }; }), @@ -510,7 +559,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { return { status: 200, - body: remappedField, + body: { + ...remappedField, + documentId: Number(documentId), + }, }; }), @@ -597,7 +649,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { return { status: 200, - body: remappedField, + body: { + ...remappedField, + documentId: Number(documentId), + }, }; }), }); diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index f6fba2f0f..091e01fb6 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -1,6 +1,12 @@ import { z } from 'zod'; -import { FieldType, ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { + FieldType, + ReadStatus, + RecipientRole, + SendStatus, + SigningStatus, +} from '@documenso/prisma/client'; /** * Documents @@ -41,15 +47,45 @@ export const ZUploadDocumentSuccessfulSchema = z.object({ export type TUploadDocumentSuccessfulSchema = z.infer; export const ZCreateDocumentMutationSchema = z.object({ - fileName: z.string(), - contentType: z.string().default('PDF'), + title: z.string().min(1), + recipients: z.array( + z.object({ + name: z.string().min(1), + email: z.string().email().min(1), + role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER), + }), + ), + meta: z.object({ + subject: z.string(), + message: z.string(), + timezone: z.string(), + dateFormat: z.string(), + redirectUrl: z.string(), + }), }); export type TCreateDocumentMutationSchema = z.infer; +export const ZCreateDocumentMutationResponseSchema = z.object({ + uploadUrl: z.string().min(1), + documentId: z.number(), + recipients: z.array( + z.object({ + recipientId: z.number(), + token: z.string(), + role: z.nativeEnum(RecipientRole), + }), + ), +}); + +export type TCreateDocumentMutationResponseSchema = z.infer< + typeof ZCreateDocumentMutationResponseSchema +>; + export const ZCreateRecipientMutationSchema = z.object({ name: z.string().min(1), email: z.string().email().min(1), + role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER), }); /** @@ -70,6 +106,7 @@ export const ZSuccessfulRecipientResponseSchema = z.object({ documentId: z.number(), email: z.string().email().min(1), name: z.string(), + role: z.nativeEnum(RecipientRole), token: z.string(), // !: Not used for now // expired: z.string(), diff --git a/packages/lib/server-only/recipient/update-recipient.ts b/packages/lib/server-only/recipient/update-recipient.ts index 0b1fa046d..e1d28ca13 100644 --- a/packages/lib/server-only/recipient/update-recipient.ts +++ b/packages/lib/server-only/recipient/update-recipient.ts @@ -1,10 +1,12 @@ import { prisma } from '@documenso/prisma'; +import type { RecipientRole } from '@documenso/prisma/client'; export type UpdateRecipientOptions = { documentId: number; recipientId: number; email?: string; name?: string; + role?: RecipientRole; }; export const updateRecipient = async ({ @@ -12,6 +14,7 @@ export const updateRecipient = async ({ recipientId, email, name, + role, }: UpdateRecipientOptions) => { const recipient = await prisma.recipient.findFirst({ where: { @@ -31,6 +34,7 @@ export const updateRecipient = async ({ data: { email: email?.toLowerCase() ?? recipient.email, name: name ?? recipient.name, + role: role ?? recipient.role, }, }); From 071475769c2c7965a2c0eaeb6a549ab99516350b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 12 Feb 2024 17:30:23 +1100 Subject: [PATCH 087/466] feat: add document page view --- .../[id]/document-page-view-button.tsx | 110 ++++++++ .../[id]/document-page-view-dropdown.tsx | 160 +++++++++++ .../[id]/document-page-view-information.tsx | 71 +++++ .../documents/[id]/document-page-view.tsx | 251 ++++++++++++++---- .../documents/[id]/edit-document.tsx | 37 ++- .../[id]/edit/document-edit-page-view.tsx | 121 +++++++++ .../(dashboard)/documents/[id]/edit/page.tsx | 11 + .../_action-items/resend-document.tsx | 2 +- .../documents/data-table-action-button.tsx | 2 +- .../documents/data-table-action-dropdown.tsx | 2 +- .../documents/duplicate-document-dialog.tsx | 2 +- .../(dashboard)/documents/upload-document.tsx | 2 +- .../templates/data-table-templates.tsx | 2 +- .../t/[teamUrl]/documents/[id]/edit/page.tsx | 21 ++ .../components/formatter/document-status.tsx | 2 +- packages/lib/constants/recipient-roles.ts | 2 +- .../document/get-document-by-id.ts | 13 + packages/ui/primitives/badge.tsx | 16 +- 18 files changed, 755 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/edit/page.tsx diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx new file mode 100644 index 000000000..334089a5f --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx @@ -0,0 +1,110 @@ +'use client'; + +import Link from 'next/link'; + +import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react'; +import { useSession } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import { trpc as trpcClient } from '@documenso/trpc/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DocumentPageViewButtonProps = { + document: Document & { + User: Pick; + Recipient: Recipient[]; + team: Pick | null; + }; + team?: Pick; +}; + +export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + + if (!session) { + return null; + } + + const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email); + + const isRecipient = !!recipient; + const isPending = document.status === DocumentStatus.PENDING; + const isComplete = document.status === DocumentStatus.COMPLETED; + const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + const role = recipient?.role; + + const documentsPath = formatDocumentsPath(document.team?.url); + + const onDownloadClick = async () => { + try { + const documentWithData = await trpcClient.document.getDocumentById.query({ + id: document.id, + teamId: team?.id, + }); + + const documentData = documentWithData?.documentData; + + if (!documentData) { + throw new Error('No document available'); + } + + await downloadPDF({ documentData, fileName: documentWithData.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } + }; + + return match({ + isRecipient, + isPending, + isComplete, + isSigned, + }) + .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( + + )) + .with({ isComplete: false }, () => ( + + )) + .with({ isComplete: true }, () => ( + + )) + .otherwise(() => null); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx new file mode 100644 index 000000000..3e108aed5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; + +import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { DocumentStatus } from '@documenso/prisma/client'; +import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; +import { trpc as trpcClient } from '@documenso/trpc/client'; +import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { ResendDocumentActionItem } from '../_action-items/resend-document'; +import { DeleteDocumentDialog } from '../delete-document-dialog'; +import { DuplicateDocumentDialog } from '../duplicate-document-dialog'; + +export type DocumentPageViewDropdownProps = { + document: Document & { + User: Pick; + Recipient: Recipient[]; + team: Pick | null; + }; + team?: Pick; +}; + +export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { + const { data: session } = useSession(); + const { toast } = useToast(); + + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + + if (!session) { + return null; + } + + const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email); + + const isOwner = document.User.id === session.user.id; + const isDraft = document.status === DocumentStatus.DRAFT; + const isComplete = document.status === DocumentStatus.COMPLETED; + const isDocumentDeletable = isOwner; + const isCurrentTeamDocument = team && document.team?.url === team.url; + + const documentsPath = formatDocumentsPath(team?.url); + + const onDownloadClick = async () => { + try { + const documentWithData = await trpcClient.document.getDocumentById.query({ + id: document.id, + teamId: team?.id, + }); + + const documentData = documentWithData?.documentData; + + if (!documentData) { + return; + } + + await downloadPDF({ documentData, fileName: document.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } + }; + + const nonSignedRecipients = document.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); + + return ( + + + + + + + Action + + {(isOwner || isCurrentTeamDocument) && !isComplete && ( + + + + Edit + + + )} + + {isComplete && ( + + + Download + + )} + + setDuplicateDialogOpen(true)}> + + Duplicate + + + setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> + + Delete + + + Share + + + + ( + e.preventDefault()}> +
+ {loading ? : } + Share Signing Card +
+
+ )} + /> +
+ + {isDocumentDeletable && ( + + )} + {isDuplicateDialogOpen && ( + + )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx new file mode 100644 index 000000000..00bbe0d83 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useMemo } from 'react'; + +import { DateTime } from 'luxon'; + +import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { useLocale } from '@documenso/lib/client-only/providers/locale'; +import type { Document, Recipient, User } from '@documenso/prisma/client'; + +export type DocumentPageViewInformationProps = { + userId: number; + document: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DocumentPageViewInformation = ({ + document, + userId, +}: DocumentPageViewInformationProps) => { + const isMounted = useIsMounted(); + const { locale } = useLocale(); + + const documentInformation = useMemo(() => { + let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy'); + let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative(); + + if (!isMounted) { + createdValue = DateTime.fromJSDate(document.createdAt) + .setLocale(locale) + .toFormat('MMMM d, yyyy'); + + lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative(); + } + + return [ + { + description: 'Uploaded by', + value: userId === document.userId ? 'You' : document.User.name ?? document.User.email, + }, + { + description: 'Created', + value: createdValue, + }, + { + description: 'Last modified', + value: lastModifiedValue, + }, + ]; + }, [isMounted, document, locale, userId]); + + return ( +
+

Information

+ +
    + {documentInformation.map((item) => ( +
  • + {item.description} + {item.value} +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index 6759d91ac..c821bfac8 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -1,22 +1,42 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { ChevronLeft, Users2 } from 'lucide-react'; +import { + CheckIcon, + ChevronLeft, + Clock, + MailIcon, + MailOpenIcon, + PenIcon, + PlusIcon, + Users2, +} from 'lucide-react'; +import { match } from 'ts-pattern'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; -import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; -import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; -import { DocumentStatus } from '~/components/formatter/document-status'; +import { + DocumentStatus as DocumentStatusComponent, + FRIENDLY_STATUS_MAP, +} from '~/components/formatter/document-status'; + +import { DocumentPageViewButton } from './document-page-view-button'; +import { DocumentPageViewDropdown } from './document-page-view-dropdown'; +import { DocumentPageViewInformation } from './document-page-view-information'; export type DocumentPageViewProps = { params: { @@ -67,65 +87,196 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) documentMeta.password = securePassword; } - const [recipients, fields] = await Promise.all([ - getRecipientsForDocument({ - documentId, - userId: user.id, - }), - getFieldsForDocument({ - documentId, - userId: user.id, - }), - ]); + const recipients = await getRecipientsForDocument({ + documentId, + userId: user.id, + }); + + const documentWithRecipients = { + ...document, + Recipient: recipients, + }; return (
- + Documents -

- {document.title} -

+
+

+ {document.title} +

-
- +
+ - {recipients.length > 0 && ( -
- + {recipients.length > 0 && ( +
+ - - {recipients.length} Recipient(s) - -
- )} + + {recipients.length} Recipient(s) + +
+ )} +
- {document.status !== InternalDocumentStatus.COMPLETED && ( - - )} +
+ + + + + - {document.status === InternalDocumentStatus.COMPLETED && ( -
- +
+
+
+
+

+ Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()} +

+ + +
+ +

+ {match(document.status) + .with( + DocumentStatus.COMPLETED, + () => 'This document has been signed by all recipients', + ) + .with( + DocumentStatus.DRAFT, + () => 'This document is currently a draft and has not been sent', + ) + .with(DocumentStatus.PENDING, () => { + const pendingRecipients = recipients.filter( + (recipient) => recipient.signingStatus === 'NOT_SIGNED', + ); + + return `Waiting on ${pendingRecipients.length} recipient${ + pendingRecipients.length > 1 ? 's' : '' + }`; + }) + .exhaustive()} +

+ +
+ +
+
+ + {/* Document information section. */} + + + {/* Recipients section. */} +
+
+

Recipients

+ + {document.status !== DocumentStatus.COMPLETED && ( + + {recipients.length === 0 ? ( + + ) : ( + + )} + + )} +
+ +
    + {recipients.length === 0 && ( +
  • + No recipients +
  • + )} + + {recipients.map((recipient) => ( +
  • + {recipient.email}

    + } + secondaryText={ +

    + {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

    + } + /> + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.SIGNED && ( + + {match(recipient.role) + .with(RecipientRole.APPROVER, () => ( + <> + + Approved + + )) + .with(RecipientRole.CC, () => + document.status === DocumentStatus.COMPLETED ? ( + <> + + Sent + + ) : ( + <> + + Ready + + ), + ) + + .with(RecipientRole.SIGNER, () => ( + <> + + Signed + + )) + .with(RecipientRole.VIEWER, () => ( + <> + + Viewed + + )) + .exhaustive()} + + )} + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.NOT_SIGNED && ( + + + Pending + + )} +
  • + ))} +
+
+
- )} +
); }; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 813458062..fe278486e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -2,10 +2,16 @@ import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; -import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { + type DocumentData, + type DocumentMeta, + DocumentStatus, + type Field, + type Recipient, + type User, +} from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -49,12 +55,9 @@ export const EditDocumentForm = ({ documentRootPath, }: EditDocumentFormProps) => { const { toast } = useToast(); - const router = useRouter(); - // controlled stepper state - const [step, setStep] = useState( - document.status === DocumentStatus.DRAFT ? 'title' : 'signers', - ); + const router = useRouter(); + const searchParams = useSearchParams(); const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation(); const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); @@ -86,6 +89,24 @@ export const EditDocumentForm = ({ }, }; + const [step, setStep] = useState(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined; + + let initialStep: EditDocumentStep = + document.status === DocumentStatus.DRAFT ? 'title' : 'signers'; + + if ( + searchParamStep && + documentFlow[searchParamStep] !== undefined && + !(recipients.length === 0 && (searchParamStep === 'subject' || searchParamStep === 'fields')) + ) { + initialStep = searchParamStep; + } + + return initialStep; + }); + const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => { try { // Custom invocation server action diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx new file mode 100644 index 000000000..87b3738bb --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -0,0 +1,121 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft, Users2 } from 'lucide-react'; + +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { Team } from '@documenso/prisma/client'; +import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; + +import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentStatus } from '~/components/formatter/document-status'; + +export type DocumentEditPageViewProps = { + params: { + id: string; + }; + team?: Team; +}; + +export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => { + const { id } = params; + + const documentId = Number(id); + + const documentRootPath = formatDocumentsPath(team?.url); + + if (!documentId || Number.isNaN(documentId)) { + redirect(documentRootPath); + } + + const { user } = await getRequiredServerComponentSession(); + + const document = await getDocumentById({ + id: documentId, + userId: user.id, + teamId: team?.id, + }).catch(() => null); + + if (!document || !document.documentData) { + redirect(documentRootPath); + } + + if (document.status === InternalDocumentStatus.COMPLETED) { + redirect(`${documentRootPath}/${documentId}`); + } + + const { documentData, documentMeta } = document; + + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + + const [recipients, fields] = await Promise.all([ + getRecipientsForDocument({ + documentId, + userId: user.id, + }), + getFieldsForDocument({ + documentId, + userId: user.id, + }), + ]); + + return ( +
+ + + Documents + + +

+ {document.title} +

+ +
+ + + {recipients.length > 0 && ( +
+ + + + {recipients.length} Recipient(s) + +
+ )} +
+ + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx new file mode 100644 index 000000000..6c613a287 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx @@ -0,0 +1,11 @@ +import { DocumentEditPageView } from './document-edit-page-view'; + +export type DocumentPageProps = { + params: { + id: string; + }; +}; + +export default function DocumentEditPage({ params }: DocumentPageProps) { + return ; +} diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx index e8e3d6130..ff2291c54 100644 --- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx @@ -119,7 +119,7 @@ export const ResendDocumentActionItem = ({ - +

Who do you want to remind?

diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 78ffd0b3b..455f50be5 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, () => ( +
+
+ + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index 060257d72..638443bf9 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -1,5 +1,7 @@ 'use client'; +import Link from 'next/link'; + import { Zap } from 'lucide-react'; import { ToggleLeft, ToggleRight } from 'lucide-react'; @@ -22,7 +24,7 @@ export default function WebhookPage() { - {webhooks?.length === 0 && ( + {webhooks && webhooks.length === 0 && ( // TODO: Perhaps add some illustrations here to make the page more engaging

@@ -31,7 +33,7 @@ export default function WebhookPage() {

)} - {webhooks?.length > 0 && ( + {webhooks && webhooks.length > 0 && (
{webhooks?.map((webhook) => (
@@ -41,9 +43,9 @@ export default function WebhookPage() {

{webhook.webhookUrl}

Event triggers

{webhook.eventTriggers.map((trigger, index) => ( -

- {trigger} -

+ + {trigger} + ))} {webhook.enabled ? (

@@ -57,8 +59,8 @@ export default function WebhookPage() {

- diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx index 269b83449..2adbaeb7a 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/multiselect-combobox.tsx @@ -14,6 +14,8 @@ import { } from '@documenso/ui/primitives/command'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; +import { truncateTitle } from '~/helpers/truncate-title'; + type ComboboxProps = { listValues: string[]; onChange: (_values: string[]) => void; @@ -53,13 +55,13 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { aria-expanded={isOpen} className="w-[200px] justify-between" > - {selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'} + {selectedValues.length > 0 ? selectedValues.length + ' selected...' : 'Select values...'} - + No value found. {allEvents.map((value: string, i: number) => ( diff --git a/packages/lib/server-only/user/get-user-webhooks.ts b/packages/lib/server-only/user/get-user-webhooks.ts new file mode 100644 index 000000000..26c47e0f4 --- /dev/null +++ b/packages/lib/server-only/user/get-user-webhooks.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetUserWebhooksByIdOptions { + id: number; +} + +export const getUserWebhooksById = async ({ id }: GetUserWebhooksByIdOptions) => { + return await prisma.user.findFirstOrThrow({ + where: { + id, + }, + select: { + email: true, + Webhooks: true, + }, + }); +}; diff --git a/packages/lib/server-only/webhooks/edit-webhook.ts b/packages/lib/server-only/webhooks/edit-webhook.ts new file mode 100644 index 000000000..4177bb2bf --- /dev/null +++ b/packages/lib/server-only/webhooks/edit-webhook.ts @@ -0,0 +1,21 @@ +import type { Prisma } from '@prisma/client'; + +import { prisma } from '@documenso/prisma'; + +export type EditWebhookOptions = { + id: number; + data: Prisma.WebhookUpdateInput; + userId: number; +}; + +export const editWebhook = async ({ id, data, userId }: EditWebhookOptions) => { + return await prisma.webhook.update({ + where: { + id, + userId, + }, + data: { + ...data, + }, + }); +}; diff --git a/packages/lib/server-only/webhooks/get-webhook-by-id.ts b/packages/lib/server-only/webhooks/get-webhook-by-id.ts new file mode 100644 index 000000000..82dbb70ef --- /dev/null +++ b/packages/lib/server-only/webhooks/get-webhook-by-id.ts @@ -0,0 +1,15 @@ +import { prisma } from '@documenso/prisma'; + +export type GetWebhookByIdOptions = { + id: number; + userId: number; +}; + +export const getWebhookById = async ({ id, userId }: GetWebhookByIdOptions) => { + return await prisma.webhook.findFirstOrThrow({ + where: { + id, + userId, + }, + }); +}; diff --git a/packages/trpc/server/webhook-router/router.ts b/packages/trpc/server/webhook-router/router.ts index 6598b856a..aeb7e6f38 100644 --- a/packages/trpc/server/webhook-router/router.ts +++ b/packages/trpc/server/webhook-router/router.ts @@ -2,11 +2,17 @@ import { TRPCError } from '@trpc/server'; import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook'; import { deleteWebhookById } from '@documenso/lib/server-only/webhooks/delete-webhook-by-id'; +import { editWebhook } from '@documenso/lib/server-only/webhooks/edit-webhook'; +import { getWebhookById } from '@documenso/lib/server-only/webhooks/get-webhook-by-id'; import { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id'; import { authenticatedProcedure, router } from '../trpc'; -import { ZCreateWebhookFormSchema } from './schema'; -import { ZDeleteWebhookSchema } from './schema'; +import { + ZCreateWebhookFormSchema, + ZDeleteWebhookMutationSchema, + ZEditWebhookMutationSchema, + ZGetWebhookByIdQuerySchema, +} from './schema'; export const webhookRouter = router({ getWebhooks: authenticatedProcedure.query(async ({ ctx }) => { @@ -19,6 +25,24 @@ export const webhookRouter = router({ }); } }), + getWebhookById: authenticatedProcedure + .input(ZGetWebhookByIdQuerySchema) + .query(async ({ input, ctx }) => { + try { + const { id } = input; + + return await getWebhookById({ + id, + userId: ctx.user.id, + }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to fetch your webhook. Please try again later.', + }); + } + }), + createWebhook: authenticatedProcedure .input(ZCreateWebhookFormSchema) .mutation(async ({ input, ctx }) => { @@ -35,7 +59,7 @@ export const webhookRouter = router({ } }), deleteWebhook: authenticatedProcedure - .input(ZDeleteWebhookSchema) + .input(ZDeleteWebhookMutationSchema) .mutation(async ({ input, ctx }) => { try { const { id } = input; @@ -51,4 +75,22 @@ export const webhookRouter = router({ }); } }), + editWebhook: authenticatedProcedure + .input(ZEditWebhookMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { id } = input; + + return await editWebhook({ + id, + data: input, + userId: ctx.user.id, + }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this webhook. Please try again later.', + }); + } + }), }); diff --git a/packages/trpc/server/webhook-router/schema.ts b/packages/trpc/server/webhook-router/schema.ts index aba409c2f..def654a70 100644 --- a/packages/trpc/server/webhook-router/schema.ts +++ b/packages/trpc/server/webhook-router/schema.ts @@ -11,10 +11,22 @@ export const ZCreateWebhookFormSchema = z.object({ enabled: z.boolean(), }); -export const ZDeleteWebhookSchema = z.object({ +export const ZGetWebhookByIdQuerySchema = z.object({ + id: z.number(), +}); + +export const ZEditWebhookMutationSchema = ZCreateWebhookFormSchema.extend({ + id: z.number(), +}); + +export const ZDeleteWebhookMutationSchema = z.object({ id: z.number(), }); export type TCreateWebhookFormSchema = z.infer; -export type TDeleteWebhookSchema = z.infer; +export type TGetWebhookByIdQuerySchema = z.infer; + +export type TDeleteWebhookMutationSchema = z.infer; + +export type TEditWebhookMutationSchema = z.infer; From cab875f68a70512d669622c215cecfd789878403 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 14 Feb 2024 13:20:40 +0000 Subject: [PATCH 091/466] fix: update create delete user sql script --- .../migration.sql | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql b/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql index 72727eadb..bfb9c2c83 100644 --- a/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql +++ b/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql @@ -1,25 +1,30 @@ -- Create deleted@documenso.com -INSERT INTO - "public"."User" ( - "email", - "emailVerified", - "password", - "createdAt", - "updatedAt", - "lastSignedIn", - "roles", - "identityProvider", - "twoFactorEnabled" - ) -VALUES - ( - 'deleted@documenso.com', - '2024-02-05 11:58:39.668 UTC', - NULL, - '2024-02-05 11:58:39.670 UTC', - '2024-02-05 11:58:39.670 UTC', - '2024-02-05 11:58:39.670 UTC', - ARRAY['USER'::TEXT]::"public"."Role" [], - CAST('GOOGLE'::TEXT AS "public"."IdentityProvider"), - FALSE - ) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM "public"."User" WHERE "email" = 'deleted@documenso.com') THEN + INSERT INTO + "public"."User" ( + "email", + "emailVerified", + "password", + "createdAt", + "updatedAt", + "lastSignedIn", + "roles", + "identityProvider", + "twoFactorEnabled" + ) + VALUES + ( + 'deleted@documenso.com', + NOW(), + NULL, + NOW(), + NOW(), + NOW(), + ARRAY['USER'::TEXT]::"public"."Role" [], + CAST('GOOGLE'::TEXT AS "public"."IdentityProvider"), + FALSE + ); + END IF; +END $$ From c680cfc24f54eece6a4a362bdc0d1c1ef60fa8ae Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 14 Feb 2024 14:52:18 +0000 Subject: [PATCH 092/466] chore: update pr based on review --- apps/web/src/components/forms/profile.tsx | 4 +--- packages/ee/server-only/stripe/delete-customer.ts | 10 ---------- packages/lib/server-only/user/delete-user.ts | 2 +- packages/trpc/server/auth-router/schema.ts | 6 ------ packages/trpc/server/profile-router/router.ts | 6 ++---- 5 files changed, 4 insertions(+), 24 deletions(-) delete mode 100644 packages/ee/server-only/stripe/delete-customer.ts diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 7a4bbdb77..a44e70940 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -124,9 +124,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { duration: 5000, }); - await signOut({ callbackUrl: '/' }); - - return; + return await signOut({ callbackUrl: '/' }); } const { token } = deleteAccountTwoFactorTokenForm.getValues(); diff --git a/packages/ee/server-only/stripe/delete-customer.ts b/packages/ee/server-only/stripe/delete-customer.ts deleted file mode 100644 index 16120de68..000000000 --- a/packages/ee/server-only/stripe/delete-customer.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { stripe } from '@documenso/lib/server-only/stripe'; -import type { User } from '@documenso/prisma/client'; - -export const deleteStripeCustomer = async (user: User) => { - if (!user.customerId) { - return null; - } - - return await stripe.customers.del(user.customerId); -}; diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 352f5c9e9..02d811b12 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -4,7 +4,7 @@ export type DeleteUserOptions = { email: string; }; -export const deleteUser = async ({ email }: DeleteUserOptions) => { +export const deletedServiceAccount = async ({ email }: DeleteUserOptions) => { const user = await prisma.user.findFirst({ where: { email: { diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index 49826d7ad..dbe42a25c 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -26,9 +26,3 @@ export const ZSignUpMutationSchema = z.object({ export type TSignUpMutationSchema = z.infer; export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true }); - -export const ZDeleteAccountMutationSchema = z.object({ - email: z.string().email(), -}); - -export type TDeleteAccountMutationSchema = z.infer; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 552057bdd..56a6eea29 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,7 +1,6 @@ import { TRPCError } from '@trpc/server'; -import { deleteStripeCustomer } from '@documenso/ee/server-only/stripe/delete-customer'; -import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; +import { deletedServiceAccount } from '@documenso/lib/server-only/user/delete-user'; import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; @@ -161,9 +160,8 @@ export const profileRouter = router({ deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { try { const user = ctx.user; - await deleteStripeCustomer(user); - return await deleteUser(user); + return await deletedServiceAccount(user); } catch (err) { let message = 'We were unable to delete your account. Please try again.'; From abab0c0a22a586e22c0f50007f300ceb4e9c519d Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 14 Feb 2024 17:11:46 +0100 Subject: [PATCH 093/466] chore: grammer and format --- .well-known/security.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.well-known/security.txt b/.well-known/security.txt index 0b00c7123..1a3f685e5 100644 --- a/.well-known/security.txt +++ b/.well-known/security.txt @@ -1,6 +1,7 @@ # General Issues Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml -# Report critical issues privately, to let us take appropriate action before publishing + +# Report critical issues privately to let us take appropriate action before publishing. Contact: mailto:security@documenso.com Preferred-Languages: en -Canonical: https://documenso.com/.well-known/security.txt +Canonical: https://documenso.com/.well-known/security.txt \ No newline at end of file From 3e12a05ab8fa665bb53f9ba71c9e9b06c59817e6 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 14 Feb 2024 17:19:48 +0100 Subject: [PATCH 094/466] chore: more grammar --- apps/marketing/public/.well-known/security.txt | 5 +++-- apps/web/public/.well-known/security.txt | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/marketing/public/.well-known/security.txt b/apps/marketing/public/.well-known/security.txt index 0b00c7123..1a3f685e5 100644 --- a/apps/marketing/public/.well-known/security.txt +++ b/apps/marketing/public/.well-known/security.txt @@ -1,6 +1,7 @@ # General Issues Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml -# Report critical issues privately, to let us take appropriate action before publishing + +# Report critical issues privately to let us take appropriate action before publishing. Contact: mailto:security@documenso.com Preferred-Languages: en -Canonical: https://documenso.com/.well-known/security.txt +Canonical: https://documenso.com/.well-known/security.txt \ No newline at end of file diff --git a/apps/web/public/.well-known/security.txt b/apps/web/public/.well-known/security.txt index 0b00c7123..1a3f685e5 100644 --- a/apps/web/public/.well-known/security.txt +++ b/apps/web/public/.well-known/security.txt @@ -1,6 +1,7 @@ # General Issues Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml -# Report critical issues privately, to let us take appropriate action before publishing + +# Report critical issues privately to let us take appropriate action before publishing. Contact: mailto:security@documenso.com Preferred-Languages: en -Canonical: https://documenso.com/.well-known/security.txt +Canonical: https://documenso.com/.well-known/security.txt \ No newline at end of file From 49cddfab38acdbefd5a385b105cdef8929991429 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 06:11:50 +0000 Subject: [PATCH 095/466] chore: lint with oxc --- .../src/app/(marketing)/open/page.tsx | 7 +- .../src/app/(marketing)/pricing/page.tsx | 4 +- .../components/(marketing)/pricing-table.tsx | 8 +- .../_action-items/resend-document.tsx | 152 +++++++++--------- packages/lib/server-only/2fa/setup-2fa.ts | 2 +- 5 files changed, 91 insertions(+), 82 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index a1fea41e4..76de85fcf 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -147,7 +147,12 @@ export default async function OpenPage() {

All our metrics, finances, and learnings are public. We believe in transparency and want to share our journey with you. You can read more about why here:{' '} - + Announcing Open Metrics

diff --git a/apps/marketing/src/app/(marketing)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx index e4c7b776a..5756f9e68 100644 --- a/apps/marketing/src/app/(marketing)/pricing/page.tsx +++ b/apps/marketing/src/app/(marketing)/pricing/page.tsx @@ -53,7 +53,7 @@ export default function PricingPage() {
@@ -166,6 +166,7 @@ export default function PricingPage() { support@documenso.com @@ -175,6 +176,7 @@ export default function PricingPage() { className="text-documenso-700 font-bold" href="https://documen.so/discord" target="_blank" + rel="noreferrer" > in our Discord-Support-Channel {' '} diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx index b65411064..80510b8e5 100644 --- a/apps/marketing/src/components/(marketing)/pricing-table.tsx +++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx @@ -123,7 +123,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {

{' '} - + The Early Adopter Deal:

@@ -133,7 +133,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {

{' '} - + Includes all upcoming features diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx index e8e3d6130..4bcb25a6c 100644 --- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx @@ -108,88 +108,86 @@ export const ResendDocumentActionItem = ({ }; return ( - <> -

- - e.preventDefault()}> - - Resend - - + + + e.preventDefault()}> + + Resend + + - - - -

Who do you want to remind?

-
-
+ + + +

Who do you want to remind?

+
+
-
- - ( - <> - {recipients.map((recipient) => ( - + + ( + <> + {recipients.map((recipient) => ( + + - - - {recipient.email} - + + {recipient.email} + - - - checked - ? onChange([...value, recipient.id]) - : onChange(value.filter((v) => v !== recipient.id)) - } - /> - - - ))} - - )} - /> - - + + + checked + ? onChange([...value, recipient.id]) + : onChange(value.filter((v) => v !== recipient.id)) + } + /> + + + ))} + + )} + /> + + - -
- - - - - -
-
-
-
- + + + +
+ + + ); }; diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts index 23f213574..bcaa8d498 100644 --- a/packages/lib/server-only/2fa/setup-2fa.ts +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -43,7 +43,7 @@ export const setupTwoFactorAuthentication = async ({ const secret = crypto.randomBytes(10); - const backupCodes = new Array(10) + const backupCodes = Array.from({ length: 10 }) .fill(null) .map(() => crypto.randomBytes(5).toString('hex')) .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase()); From 769eaa0ed99d41839e66c72649a5e05e626a78f9 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 07:01:41 +0000 Subject: [PATCH 096/466] feat: add roles to templates recipients --- .../recipient/set-recipients-for-template.ts | 4 + .../template/create-document-from-template.ts | 1 + .../trpc/server/recipient-router/router.ts | 1 + .../trpc/server/recipient-router/schema.ts | 1 + .../primitives/document-flow/add-signers.tsx | 10 +- .../ui/primitives/recipient-role-icons.tsx | 10 ++ .../template-flow/add-template-fields.tsx | 109 ++++++++++-------- .../add-template-placeholder-recipients.tsx | 62 ++++++++-- ...d-template-placeholder-recipients.types.ts | 3 + 9 files changed, 135 insertions(+), 66 deletions(-) create mode 100644 packages/ui/primitives/recipient-role-icons.tsx diff --git a/packages/lib/server-only/recipient/set-recipients-for-template.ts b/packages/lib/server-only/recipient/set-recipients-for-template.ts index 7c96bcf44..5315711a5 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-template.ts @@ -1,4 +1,5 @@ import { prisma } from '@documenso/prisma'; +import type { RecipientRole } from '@documenso/prisma/client'; import { nanoid } from '../../universal/id'; @@ -9,6 +10,7 @@ export type SetRecipientsForTemplateOptions = { id?: number; email: string; name: string; + role: RecipientRole; }[]; }; @@ -84,11 +86,13 @@ export const setRecipientsForTemplate = async ({ update: { name: recipient.name, email: recipient.email, + role: recipient.role, templateId, }, create: { name: recipient.name, email: recipient.email, + role: recipient.role, token: nanoid(), templateId, }, diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index c520d4ce1..fc4e161e4 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -57,6 +57,7 @@ export const createDocumentFromTemplate = async ({ create: template.Recipient.map((recipient) => ({ email: recipient.email, name: recipient.name, + role: recipient.role, token: nanoid(), })), }, diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index c36b09ec9..d28edb6c3 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -53,6 +53,7 @@ export const recipientRouter = router({ id: signer.nativeId, email: signer.email, name: signer.name, + role: signer.role, })), }); } catch (err) { diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index a6b4e0d11..edcd34ed6 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -34,6 +34,7 @@ export const ZAddTemplateSignersMutationSchema = z nativeId: z.number().optional(), email: z.string().email().min(1), name: z.string(), + role: z.nativeEnum(RecipientRole), }), ), }) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index b1341c6ca..b13e220f3 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -4,7 +4,7 @@ import React, { useId } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; -import { BadgeCheck, Copy, Eye, PencilLine, Plus, Trash } from 'lucide-react'; +import { Plus, Trash } from 'lucide-react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; @@ -17,6 +17,7 @@ import { Button } from '../button'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; import { Label } from '../label'; +import { ROLE_ICONS } from '../recipient-role-icons'; import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; import { useStep } from '../stepper'; import { useToast } from '../use-toast'; @@ -32,13 +33,6 @@ import { import { ShowFieldItem } from './show-field-item'; import type { DocumentFlowStep } from './types'; -const ROLE_ICONS: Record = { - SIGNER: , - APPROVER: , - CC: , - VIEWER: , -}; - export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; diff --git a/packages/ui/primitives/recipient-role-icons.tsx b/packages/ui/primitives/recipient-role-icons.tsx new file mode 100644 index 000000000..5bc4f34b9 --- /dev/null +++ b/packages/ui/primitives/recipient-role-icons.tsx @@ -0,0 +1,10 @@ +import { BadgeCheck, Copy, Eye, PencilLine } from 'lucide-react'; + +import type { RecipientRole } from '.prisma/client'; + +export const ROLE_ICONS: Record = { + SIGNER: , + APPROVER: , + CC: , + VIEWER: , +}; diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx index bb9c304d9..602bd749b 100644 --- a/packages/ui/primitives/template-flow/add-template-fields.tsx +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Caveat } from 'next/font/google'; @@ -10,9 +10,10 @@ import { useFieldArray, useForm } from 'react-hook-form'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { nanoid } from '@documenso/lib/universal/id'; import type { Field, Recipient } from '@documenso/prisma/client'; -import { FieldType } from '@documenso/prisma/client'; +import { FieldType, RecipientRole } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -291,6 +292,28 @@ export const AddTemplateFieldsFormPartial = ({ setSelectedSigner(recipients[0]); }, [recipients]); + const recipientsByRole = useMemo(() => { + const recipientsByRole: Record = { + CC: [], + VIEWER: [], + SIGNER: [], + APPROVER: [], + }; + + recipients.forEach((recipient) => { + recipientsByRole[recipient.role].push(recipient); + }); + + return recipientsByRole; + }, [recipients]); + + const recipientsByRoleToDisplay = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter( + ([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER, + ); + }, [recipientsByRole]); + return ( <> @@ -363,55 +386,49 @@ export const AddTemplateFieldsFormPartial = ({ - - {recipients.map((recipient, index) => ( - { - setSelectedSigner(recipient); - setShowRecipientsSelector(false); - }} - > - {/* {recipient.sendStatus !== SendStatus.SENT ? ( - - ) : ( - - - - - - This document has already been sent to this recipient. You can no - longer edit this recipient. - - - )} */} + {recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => ( + +
+ {`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`} +
- {recipient.name && ( + {recipients.length === 0 && ( +
+ No recipients with this role +
+ )} + + {recipients.map((recipient) => ( + { + setSelectedSigner(recipient); + setShowRecipientsSelector(false); + }} + > - {recipient.name} ({recipient.email}) - - )} + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} - {!recipient.name && ( - - {recipient.email} + {!recipient.name && ( + {recipient.email} + )} - )} - - ))} -
+
+ ))} +
+ ))} diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx index ebe48b562..87ec48ad1 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -5,10 +5,10 @@ import React, { useId, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; import { Plus, Trash } from 'lucide-react'; -import { useFieldArray, useForm } from 'react-hook-form'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { nanoid } from '@documenso/lib/universal/id'; -import type { Field, Recipient } from '@documenso/prisma/client'; +import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { Input } from '@documenso/ui/primitives/input'; @@ -21,6 +21,8 @@ import { DocumentFlowFormContainerStep, } from '../document-flow/document-flow-root'; import type { DocumentFlowStep } from '../document-flow/types'; +import { ROLE_ICONS } from '../recipient-role-icons'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; import { useStep } from '../stepper'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; @@ -59,12 +61,14 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ formId: String(recipient.id), name: recipient.name, email: recipient.email, + role: recipient.role, })) : [ { formId: initialId, name: `Recipient 1`, email: `recipient.1@documenso.com`, + role: RecipientRole.SIGNER, }, ], }, @@ -86,6 +90,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ formId: nanoid(12), name: `Recipient ${placeholderRecipientCount}`, email: `recipient.${placeholderRecipientCount}@documenso.com`, + role: RecipientRole.SIGNER, }); setPlaceholderRecipientCount((count) => count + 1); @@ -95,12 +100,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ removeSigner(index); }; - const onKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { - onAddPlaceholderRecipient(); - } - }; - return ( <> @@ -113,10 +112,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ className="flex flex-wrap items-end gap-x-4" >
- +
+
+ ( + + )} + /> +
+
+
+ )} + + + {data && ( +
    + {hasNextPage && ( +
  • +
    +
    +
    + +
    +
    +
    + + +
  • + )} + + {documentAuditLogs.map((auditLog, auditLogIndex) => ( +
  • +
    +
    +
    + +
    + {match(auditLog.type) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => ( +
    +
    + )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => ( +
    +
    + )) + .with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => ( +
    +
    + )) + .otherwise(() => ( +
    + ))} +
    + +

    + + {formatDocumentAuditLogAction(auditLog, userId).prefix} + {' '} + {formatDocumentAuditLogAction(auditLog, userId).description} +

    + + +
  • + ))} +
+ )} +
+ + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx new file mode 100644 index 000000000..37d2cd35e --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx @@ -0,0 +1,115 @@ +import Link from 'next/link'; + +import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import type { Document, Recipient } from '@documenso/prisma/client'; +import { SignatureIcon } from '@documenso/ui/icons/signature'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; + +export type DocumentPageViewRecipientsProps = { + document: Document & { + Recipient: Recipient[]; + }; + documentRootPath: string; +}; + +export const DocumentPageViewRecipients = ({ + document, + documentRootPath, +}: DocumentPageViewRecipientsProps) => { + const recipients = document.Recipient; + + return ( +
+
+

Recipients

+ + {document.status !== DocumentStatus.COMPLETED && ( + + {recipients.length === 0 ? ( + + ) : ( + + )} + + )} +
+ +
    + {recipients.length === 0 && ( +
  • No recipients
  • + )} + + {recipients.map((recipient) => ( +
  • + {recipient.email}

    } + secondaryText={ +

    + {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

    + } + /> + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.SIGNED && ( + + {match(recipient.role) + .with(RecipientRole.APPROVER, () => ( + <> + + Approved + + )) + .with(RecipientRole.CC, () => + document.status === DocumentStatus.COMPLETED ? ( + <> + + Sent + + ) : ( + <> + + Ready + + ), + ) + + .with(RecipientRole.SIGNER, () => ( + <> + + Signed + + )) + .with(RecipientRole.VIEWER, () => ( + <> + + Viewed + + )) + .exhaustive()} + + )} + + {document.status !== DocumentStatus.DRAFT && + recipient.signingStatus === SigningStatus.NOT_SIGNED && ( + + + Pending + + )} +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index c821bfac8..c64b8650a 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -1,34 +1,23 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { - CheckIcon, - ChevronLeft, - Clock, - MailIcon, - MailOpenIcon, - PenIcon, - PlusIcon, - Users2, -} from 'lucide-react'; +import { ChevronLeft, Clock9, Users2 } from 'lucide-react'; import { match } from 'ts-pattern'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; -import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client'; -import { SignatureIcon } from '@documenso/ui/icons/signature'; -import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; import { DocumentStatus as DocumentStatusComponent, FRIENDLY_STATUS_MAP, @@ -37,6 +26,8 @@ import { import { DocumentPageViewButton } from './document-page-view-button'; import { DocumentPageViewDropdown } from './document-page-view-dropdown'; import { DocumentPageViewInformation } from './document-page-view-information'; +import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity'; +import { DocumentPageViewRecipients } from './document-page-view-recipients'; export type DocumentPageViewProps = { params: { @@ -104,27 +95,38 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) Documents -
-

- {document.title} -

+
+
+

+ {document.title} +

-
- +
+ - {recipients.length > 0 && ( -
- + {recipients.length > 0 && ( +
+ - - {recipients.length} Recipient(s) - -
- )} + + {recipients.length} Recipient(s) + +
+ )} +
+
+ +
+ + +
@@ -139,8 +141,8 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
-
-
+
+

Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()} @@ -180,100 +182,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) {/* Recipients section. */} -
-
-

Recipients

+ - {document.status !== DocumentStatus.COMPLETED && ( - - {recipients.length === 0 ? ( - - ) : ( - - )} - - )} -
- -
    - {recipients.length === 0 && ( -
  • - No recipients -
  • - )} - - {recipients.map((recipient) => ( -
  • - {recipient.email}

    - } - secondaryText={ -

    - {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} -

    - } - /> - - {document.status !== DocumentStatus.DRAFT && - recipient.signingStatus === SigningStatus.SIGNED && ( - - {match(recipient.role) - .with(RecipientRole.APPROVER, () => ( - <> - - Approved - - )) - .with(RecipientRole.CC, () => - document.status === DocumentStatus.COMPLETED ? ( - <> - - Sent - - ) : ( - <> - - Ready - - ), - ) - - .with(RecipientRole.SIGNER, () => ( - <> - - Signed - - )) - .with(RecipientRole.VIEWER, () => ( - <> - - Viewed - - )) - .exhaustive()} - - )} - - {document.status !== DocumentStatus.DRAFT && - recipient.signingStatus === SigningStatus.NOT_SIGNED && ( - - - Pending - - )} -
  • - ))} -
-
+ {/* Recent activity section. */} +

diff --git a/apps/web/src/components/document/document-history-sheet-changes.tsx b/apps/web/src/components/document/document-history-sheet-changes.tsx new file mode 100644 index 000000000..ef3985a61 --- /dev/null +++ b/apps/web/src/components/document/document-history-sheet-changes.tsx @@ -0,0 +1,28 @@ +'use client'; + +import React from 'react'; + +import { Badge } from '@documenso/ui/primitives/badge'; + +export type DocumentHistorySheetChangesProps = { + values: { + key: string | React.ReactNode; + value: string | React.ReactNode; + }[]; +}; + +export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => { + return ( + + {values.map(({ key, value }, i) => ( +

+ {key}: + {value} +

+ ))} +
+ ); +}; diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx new file mode 100644 index 000000000..29d9a9c96 --- /dev/null +++ b/apps/web/src/components/document/document-history-sheet.tsx @@ -0,0 +1,316 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { ArrowRightIcon, Loader } from 'lucide-react'; +import { match } from 'ts-pattern'; +import { UAParser } from 'ua-parser-js'; + +import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { DocumentHistorySheetChanges } from './document-history-sheet-changes'; + +export type DocumentHistorySheetProps = { + documentId: number; + userId: number; + isMenuOpen?: boolean; + onMenuOpenChange?: (_value: boolean) => void; + children?: React.ReactNode; +}; + +export const DocumentHistorySheet = ({ + documentId, + userId, + isMenuOpen, + onMenuOpenChange, + children, +}: DocumentHistorySheetProps) => { + const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false); + + const { + data, + isLoading, + isLoadingError, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = trpc.document.findDocumentAuditLogs.useInfiniteQuery( + { + documentId, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]); + + const extractBrowser = (userAgent?: string | null) => { + if (!userAgent) { + return 'Unknown'; + } + + const parser = new UAParser(userAgent); + + parser.setUA(userAgent); + + const result = parser.getResult(); + + return result.browser.name; + }; + + /** + * Applies the following formatting for a given text: + * - Uppercase first lower, lowercase rest + * - Replace _ with spaces + * + * @param text The text to format + * @returns The formatted text + */ + const formatGenericText = (text: string) => { + return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' '); + }; + + return ( + + {children && {children}} + + +
+

Document history

+ +
+ + {isLoading && ( +
+ +
+ )} + + {isLoadingError && ( +
+

Unable to load document history

+ +
+ )} + + {data && ( +
    + {documentAuditLogs.map((auditLog) => ( +
  • +
    + + + {(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()} + + + +
    +

    + {formatDocumentAuditLogActionString(auditLog, userId)} +

    +

    + +

    +
    +
    + + {match(auditLog) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, + () => null, + ) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, + ({ data }) => { + const values = [ + { + key: 'Email', + value: data.recipientEmail, + }, + { + key: 'Role', + value: formatGenericText(data.recipientRole), + }, + ]; + + if (data.recipientName) { + values.unshift({ + key: 'Name', + value: data.recipientName, + }); + } + + return ; + }, + ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => { + if (data.changes.length === 0) { + return null; + } + + return ( + ({ + key: formatGenericText(type), + value: ( + + {type === 'ROLE' ? formatGenericText(from) : from} + + {type === 'ROLE' ? formatGenericText(to) : to} + + ), + }))} + /> + ); + }) + .with( + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, + { type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, + ({ data }) => ( + + ), + ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => { + if (data.changes.length === 0) { + return null; + } + + return ( + ({ + key: formatGenericText(change.type), + value: change.type === 'PASSWORD' ? '*********' : change.to, + }))} + /> + ); + }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => ( + + )) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ( + + )) + .exhaustive()} + + {isUserDetailsVisible && ( + <> +
    + + IP: {auditLog.ipAddress ?? 'Unknown'} + + + + Browser: {extractBrowser(auditLog.userAgent)} + +
    + + )} +
  • + ))} + + {hasNextPage && ( +
    + +
    + )} +
+ )} +
+
+ ); +}; diff --git a/apps/web/src/components/formatter/locale-date.tsx b/apps/web/src/components/formatter/locale-date.tsx index 7262a9a57..98a115f60 100644 --- a/apps/web/src/components/formatter/locale-date.tsx +++ b/apps/web/src/components/formatter/locale-date.tsx @@ -1,7 +1,7 @@ 'use client'; import type { HTMLAttributes } from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { DateTimeFormatOptions } from 'luxon'; import { DateTime } from 'luxon'; @@ -10,7 +10,7 @@ import { useLocale } from '@documenso/lib/client-only/providers/locale'; export type LocaleDateProps = HTMLAttributes & { date: string | number | Date; - format?: DateTimeFormatOptions; + format?: DateTimeFormatOptions | string; }; /** @@ -22,13 +22,24 @@ export type LocaleDateProps = HTMLAttributes & { export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => { const { locale } = useLocale(); + const formatDateTime = useCallback( + (date: DateTime) => { + if (typeof format === 'string') { + return date.toFormat(format); + } + + return date.toLocaleString(format); + }, + [format], + ); + const [localeDate, setLocaleDate] = useState(() => - DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format), + formatDateTime(DateTime.fromJSDate(new Date(date)).setLocale(locale)), ); useEffect(() => { - setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format)); - }, [date, format]); + setLocaleDate(formatDateTime(DateTime.fromJSDate(new Date(date)))); + }, [date, format, formatDateTime]); return ( diff --git a/packages/lib/constants/document-audit-logs.ts b/packages/lib/constants/document-audit-logs.ts new file mode 100644 index 000000000..8ae654977 --- /dev/null +++ b/packages/lib/constants/document-audit-logs.ts @@ -0,0 +1,19 @@ +import { DOCUMENT_EMAIL_TYPE } from '../types/document-audit-logs'; + +export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = { + [DOCUMENT_EMAIL_TYPE.SIGNING_REQUEST]: { + description: 'Signing request', + }, + [DOCUMENT_EMAIL_TYPE.VIEW_REQUEST]: { + description: 'Viewing request', + }, + [DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: { + description: 'Approval request', + }, + [DOCUMENT_EMAIL_TYPE.CC]: { + description: 'CC', + }, + [DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: { + description: 'Document completed', + }, +} satisfies Record; diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts index d86026782..44e4c34da 100644 --- a/packages/lib/constants/recipient-roles.ts +++ b/packages/lib/constants/recipient-roles.ts @@ -1,29 +1,31 @@ import { RecipientRole } from '@documenso/prisma/client'; -export const RECIPIENT_ROLES_DESCRIPTION: { - [key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string }; -} = { +export const RECIPIENT_ROLES_DESCRIPTION = { [RecipientRole.APPROVER]: { actionVerb: 'Approve', + actioned: 'Approved', progressiveVerb: 'Approving', roleName: 'Approver', }, [RecipientRole.CC]: { actionVerb: 'CC', + actioned: 'CCed', progressiveVerb: 'CC', roleName: 'Cc', }, [RecipientRole.SIGNER]: { actionVerb: 'Sign', + actioned: 'Signed', progressiveVerb: 'Signing', roleName: 'Signer', }, [RecipientRole.VIEWER]: { actionVerb: 'View', + actioned: 'Viewed', progressiveVerb: 'Viewing', roleName: 'Viewer', }, -}; +} satisfies Record; export const RECIPIENT_ROLE_TO_EMAIL_TYPE = { [RecipientRole.SIGNER]: 'SIGNING_REQUEST', diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 5a1c1594e..d4781f280 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({ }, }); - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED, - documentId, - user, - requestMetadata, - data: { - changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta), - }, - }), - }); + const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta); + + if (changes.length > 0) { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED, + documentId, + user, + requestMetadata, + data: { + changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta), + }, + }), + }); + } return upsertedDocumentMeta; }); diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 22365a727..473177b9b 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -9,27 +9,72 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; export type DeleteDocumentOptions = { id: number; userId: number; status: DocumentStatus; + requestMetadata?: RequestMetadata; }; -export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => { +export const deleteDocument = async ({ + id, + userId, + status, + requestMetadata, +}: DeleteDocumentOptions) => { + await prisma.document.findFirstOrThrow({ + where: { + id, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + }); + + const user = await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + // if the document is a draft, hard-delete if (status === DocumentStatus.DRAFT) { - return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } }); + return await prisma.$transaction(async (tx) => { + // Currently redundant since deleting a document will delete the audit logs. + // However may be useful if we disassociate audit lgos and documents if required. + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + documentId: id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + user, + requestMetadata, + data: { + type: 'HARD', + }, + }), + }); + + return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } }); + }); } // if the document is pending, send cancellation emails to all recipients if (status === DocumentStatus.PENDING) { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - }); - const document = await prisma.document.findUnique({ where: { id, @@ -77,12 +122,26 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio } // If the document is not a draft, only soft-delete. - return await prisma.document.update({ - where: { - id, - }, - data: { - deletedAt: new Date().toISOString(), - }, + return await prisma.$transaction(async (tx) => { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + documentId: id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + user, + requestMetadata, + data: { + type: 'SOFT', + }, + }), + }); + + return await tx.document.update({ + where: { + id, + }, + data: { + deletedAt: new Date().toISOString(), + }, + }); }); }; diff --git a/packages/lib/server-only/document/find-document-audit-logs.ts b/packages/lib/server-only/document/find-document-audit-logs.ts new file mode 100644 index 000000000..4f423ce8c --- /dev/null +++ b/packages/lib/server-only/document/find-document-audit-logs.ts @@ -0,0 +1,115 @@ +import type { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { prisma } from '@documenso/prisma'; +import type { DocumentAuditLog } from '@documenso/prisma/client'; +import type { Prisma } from '@documenso/prisma/client'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { parseDocumentAuditLogData } from '../../utils/document-audit-logs'; + +export interface FindDocumentAuditLogsOptions { + userId: number; + documentId: number; + page?: number; + perPage?: number; + orderBy?: { + column: keyof DocumentAuditLog; + direction: 'asc' | 'desc'; + }; + cursor?: string; + filterForRecentActivity?: boolean; +} + +export const findDocumentAuditLogs = async ({ + userId, + documentId, + page = 1, + perPage = 30, + orderBy, + cursor, + filterForRecentActivity, +}: FindDocumentAuditLogsOptions) => { + const orderByColumn = orderBy?.column ?? 'createdAt'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + }); + + const whereClause: Prisma.DocumentAuditLogWhereInput = { + documentId, + }; + + // Filter events down to what we consider recent activity. + if (filterForRecentActivity) { + whereClause.OR = [ + { + type: { + in: [ + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + ], + }, + }, + { + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + data: { + path: ['isResending'], + equals: true, + }, + }, + ]; + } + + const [data, count] = await Promise.all([ + prisma.documentAuditLog.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage + 1, + orderBy: { + [orderByColumn]: orderByDirection, + }, + cursor: cursor ? { id: cursor } : undefined, + }), + prisma.documentAuditLog.count({ + where: whereClause, + }), + ]); + + let nextCursor: string | undefined = undefined; + + const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog)); + + if (parsedData.length > perPage) { + const nextItem = parsedData.pop(); + nextCursor = nextItem!.id; + } + + return { + data: parsedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + nextCursor, + } satisfies FindResultSet & { nextCursor?: string }; +}; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index fc174c084..aa44ccedf 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -152,13 +152,27 @@ export const sendDocument = async ({ }), ); - const updatedDocument = await prisma.document.update({ - where: { - id: documentId, - }, - data: { - status: DocumentStatus.PENDING, - }, + const updatedDocument = await prisma.$transaction(async (tx) => { + if (document.status === DocumentStatus.DRAFT) { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, + documentId: document.id, + requestMetadata, + user, + data: {}, + }), + }); + } + + return await tx.document.update({ + where: { + id: documentId, + }, + data: { + status: DocumentStatus.PENDING, + }, + }); }); return updatedDocument; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index e6a954603..14d594786 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -21,15 +21,24 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'RECIPIENT_UPDATED', // Document events. + 'DOCUMENT_COMPLETED', // When the document is sealed and fully completed. + 'DOCUMENT_CREATED', // When the document is created. + 'DOCUMENT_DELETED', // When the document is soft deleted. + 'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient. + 'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient. + 'DOCUMENT_META_UPDATED', // When the document meta data is updated. + 'DOCUMENT_OPENED', // When the document is opened by a recipient. + 'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document. + 'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING. + 'DOCUMENT_TITLE_UPDATED', // When the document title is updated. +]); + +export const ZDocumentAuditLogEmailTypeSchema = z.enum([ + 'SIGNING_REQUEST', + 'VIEW_REQUEST', + 'APPROVE_REQUEST', + 'CC', 'DOCUMENT_COMPLETED', - 'DOCUMENT_CREATED', - 'DOCUMENT_DELETED', - 'DOCUMENT_FIELD_INSERTED', - 'DOCUMENT_FIELD_UNINSERTED', - 'DOCUMENT_META_UPDATED', - 'DOCUMENT_OPENED', - 'DOCUMENT_TITLE_UPDATED', - 'DOCUMENT_RECIPIENT_COMPLETED', ]); export const ZDocumentMetaDiffTypeSchema = z.enum([ @@ -40,10 +49,12 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([ 'SUBJECT', 'TIMEZONE', ]); + export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']); export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']); export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum; +export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum; export const DOCUMENT_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum; export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum; export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum; @@ -140,13 +151,7 @@ const ZBaseRecipientDataSchema = z.object({ export const ZDocumentAuditLogEventEmailSentSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT), data: ZBaseRecipientDataSchema.extend({ - emailType: z.enum([ - 'SIGNING_REQUEST', - 'VIEW_REQUEST', - 'APPROVE_REQUEST', - 'CC', - 'DOCUMENT_COMPLETED', - ]), + emailType: ZDocumentAuditLogEmailTypeSchema, isResending: z.boolean(), }), }); @@ -171,6 +176,16 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({ }), }); +/** + * Event: Document deleted. + */ +export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED), + data: z.object({ + type: z.enum(['SOFT', 'HARD']), + }), +}); + /** * Event: Document field inserted. */ @@ -247,6 +262,14 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({ data: ZBaseRecipientDataSchema, }); +/** + * Event: Document sent. + */ +export const ZDocumentAuditLogEventDocumentSentSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT), + data: z.object({}), +}); + /** * Event: Document title updated. */ @@ -314,6 +337,11 @@ export const ZDocumentAuditLogBaseSchema = z.object({ id: z.string(), createdAt: z.date(), documentId: z.number(), + name: z.string().optional().nullable(), + email: z.string().optional().nullable(), + userId: z.number().optional().nullable(), + userAgent: z.string().optional().nullable(), + ipAddress: z.string().optional().nullable(), }); export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( @@ -321,11 +349,13 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventEmailSentSchema, ZDocumentAuditLogEventDocumentCompletedSchema, ZDocumentAuditLogEventDocumentCreatedSchema, + ZDocumentAuditLogEventDocumentDeletedSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema, ZDocumentAuditLogEventDocumentMetaUpdatedSchema, ZDocumentAuditLogEventDocumentOpenedSchema, ZDocumentAuditLogEventDocumentRecipientCompleteSchema, + ZDocumentAuditLogEventDocumentSentSchema, ZDocumentAuditLogEventDocumentTitleUpdatedSchema, ZDocumentAuditLogEventFieldCreatedSchema, ZDocumentAuditLogEventFieldRemovedSchema, @@ -348,3 +378,8 @@ export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer< export type TDocumentAuditLogRecipientDiffSchema = z.infer< typeof ZDocumentAuditLogRecipientDiffSchema >; + +export type DocumentAuditLogByType = Extract< + TDocumentAuditLog, + { type: T } +>; diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index dcc3932e9..65ffb2817 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -1,5 +1,14 @@ -import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client'; +import { match } from 'ts-pattern'; +import type { + DocumentAuditLog, + DocumentMeta, + Field, + Recipient, + RecipientRole, +} from '@documenso/prisma/client'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '../constants/recipient-roles'; import type { TDocumentAuditLog, TDocumentAuditLogDocumentMetaDiffSchema, @@ -7,6 +16,7 @@ import type { TDocumentAuditLogRecipientDiffSchema, } from '../types/document-audit-logs'; import { + DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_META_DIFF_TYPE, FIELD_DIFF_TYPE, RECIPIENT_DIFF_TYPE, @@ -58,6 +68,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument // Handle any required migrations here. if (!data.success) { + console.error(data.error); throw new Error('Migration required'); } @@ -203,3 +214,114 @@ export const diffDocumentMetaChanges = ( return diffs; }; + +/** + * Formats the audit log into a description of the action. + * + * Provide a userId to prefix the action with the user, example 'X did Y'. + */ +export const formatDocumentAuditLogActionString = ( + auditLog: TDocumentAuditLog, + userId?: number, +) => { + const { prefix, description } = formatDocumentAuditLogAction(auditLog, userId); + + return prefix ? `${prefix} ${description}` : description; +}; + +/** + * Formats the audit log into a description of the action. + * + * Provide a userId to prefix the action with the user, example 'X did Y'. + */ +export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId?: number) => { + let prefix = userId === auditLog.userId ? 'You' : auditLog.name || auditLog.email || ''; + + const description = match(auditLog) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({ + anonymous: 'A field was added', + identified: 'added a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({ + anonymous: 'A field was removed', + identified: 'removed a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({ + anonymous: 'A field was updated', + identified: 'updated a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({ + anonymous: 'A recipient was added', + identified: 'added a recipient', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({ + anonymous: 'A recipient was removed', + identified: 'removed a recipient', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({ + anonymous: 'A recipient was updated', + identified: 'updated a recipient', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({ + anonymous: 'Document created', + identified: 'created the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({ + anonymous: 'Document deleted', + identified: 'deleted the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({ + anonymous: 'Field signed', + identified: 'signed a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({ + anonymous: 'Field unsigned', + identified: 'unsigned a field', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({ + anonymous: 'Document updated', + identified: 'updated the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({ + anonymous: 'Document opened', + identified: 'opened the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({ + anonymous: 'Document title updated', + identified: 'updated the document title', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({ + anonymous: 'Document sent', + identified: 'sent the document', + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const action = RECIPIENT_ROLES_DESCRIPTION[data.recipientRole as RecipientRole]?.actioned; + + const value = action ? `${action.toLowerCase()} the document` : 'completed their task'; + + return { + anonymous: `Recipient ${value}`, + identified: value, + }; + }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({ + anonymous: `Email ${data.isResending ? 'resent' : 'sent'}`, + identified: `${data.isResending ? 'resent' : 'sent'} an email`, + })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => { + // Clear the prefix since this should be considered an 'anonymous' event. + prefix = ''; + + return { + anonymous: 'Document completed', + identified: 'Document completed', + }; + }) + .exhaustive(); + + return { + prefix, + description: prefix ? description.identified : description.anonymous, + }; +}; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index aebc6e505..cd9491fd6 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -6,6 +6,7 @@ import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/ups import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id'; +import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; @@ -21,6 +22,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZCreateDocumentMutationSchema, ZDeleteDraftDocumentMutationSchema, + ZFindDocumentAuditLogsQuerySchema, ZGetDocumentByIdQuerySchema, ZGetDocumentByTokenQuerySchema, ZResendDocumentMutationSchema, @@ -111,7 +113,12 @@ export const documentRouter = router({ const userId = ctx.user.id; - return await deleteDocument({ id, userId, status }); + return await deleteDocument({ + id, + userId, + status, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); } catch (err) { console.error(err); @@ -122,6 +129,30 @@ export const documentRouter = router({ } }), + findDocumentAuditLogs: authenticatedProcedure + .input(ZFindDocumentAuditLogsQuerySchema) + .query(async ({ input, ctx }) => { + try { + const { perPage, documentId, cursor, filterForRecentActivity, orderBy } = input; + + return await findDocumentAuditLogs({ + perPage, + documentId, + cursor, + filterForRecentActivity, + orderBy, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find audit logs for this document. Please try again later.', + }); + } + }), + setTitleForDocument: authenticatedProcedure .input(ZSetTitleForDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 899baa41f..83c05b3b3 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -1,8 +1,21 @@ import { z } from 'zod'; import { URL_REGEX } from '@documenso/lib/constants/url-regex'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; +export const ZFindDocumentAuditLogsQuerySchema = ZBaseTableSearchParamsSchema.extend({ + documentId: z.number().min(1), + cursor: z.string().optional(), + filterForRecentActivity: z.boolean().optional(), + orderBy: z + .object({ + column: z.enum(['createdAt', 'type']), + direction: z.enum(['asc', 'desc']), + }) + .optional(), +}); + export const ZGetDocumentByIdQuerySchema = z.object({ id: z.number().min(1), teamId: z.number().min(1).optional(), diff --git a/packages/trpc/server/team-router/schema.ts b/packages/trpc/server/team-router/schema.ts index 953b12490..75c307e35 100644 --- a/packages/trpc/server/team-router/schema.ts +++ b/packages/trpc/server/team-router/schema.ts @@ -3,10 +3,11 @@ import { z } from 'zod'; import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; import { TeamMemberRole } from '@documenso/prisma/client'; +// Consider refactoring to use ZBaseTableSearchParamsSchema. const GenericFindQuerySchema = z.object({ term: z.string().optional(), - page: z.number().optional(), - perPage: z.number().optional(), + page: z.number().min(1).optional(), + perPage: z.number().min(1).optional(), }); /** diff --git a/packages/ui/primitives/sheet.tsx b/packages/ui/primitives/sheet.tsx index a6326de0f..ef5348e59 100644 --- a/packages/ui/primitives/sheet.tsx +++ b/packages/ui/primitives/sheet.tsx @@ -143,14 +143,17 @@ const sheetVariants = cva( export interface DialogContentProps extends React.ComponentPropsWithoutRef, - VariantProps {} + VariantProps { + showOverlay?: boolean; + sheetClass?: string; +} const SheetContent = React.forwardRef< React.ElementRef, DialogContentProps ->(({ position, size, className, children, ...props }, ref) => ( +>(({ position, size, className, sheetClass, showOverlay = true, children, ...props }, ref) => ( - + {showOverlay && } Date: Thu, 15 Feb 2024 20:42:17 +1100 Subject: [PATCH 098/466] fix: styling --- .../documents/[id]/document-page-view-recent-activity.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx index ef7d2e498..5890f8aa2 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recent-activity.tsx @@ -37,6 +37,7 @@ export const DocumentPageViewRecentActivity = ({ column: 'createdAt', direction: 'asc', }, + perPage: 10, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -77,7 +78,7 @@ export const DocumentPageViewRecentActivity = ({ {hasNextPage && (
  • -
    +
    From fddd860d15a69a804493babc70120f9ccc2d6499 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 11:33:43 +0000 Subject: [PATCH 099/466] chore: code refactor to avoid repetitions --- apps/web/src/components/forms/profile.tsx | 32 +++++++++----------- packages/lib/server-only/user/delete-user.ts | 3 +- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index a44e70940..23861c9fc 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -113,18 +113,22 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; + const deleteAccoutAndSignOut = async () => { + await deleteAccount(); + + toast({ + title: 'Account deleted', + description: 'Your account has been deleted successfully.', + duration: 5000, + }); + + return await signOut({ callbackUrl: '/' }); + }; + const onDeleteAccount = async (hasTwoFactorAuthentication: boolean) => { try { if (!hasTwoFactorAuthentication) { - await deleteAccount(); - - toast({ - title: 'Account deleted', - description: 'Your account has been deleted successfully.', - duration: 5000, - }); - - return await signOut({ callbackUrl: '/' }); + return await deleteAccoutAndSignOut(); } const { token } = deleteAccountTwoFactorTokenForm.getValues(); @@ -140,15 +144,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { throw new Error('We were unable to validate your Two Factor Authentication token.'); }); - await deleteAccount(); - - toast({ - title: 'Account deleted', - description: 'Your account has been deleted successfully.', - duration: 5000, - }); - - await signOut({ callbackUrl: '/' }); + await deleteAccoutAndSignOut(); } catch (err) { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({ diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 02d811b12..65a74ac42 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -1,4 +1,5 @@ import { prisma } from '@documenso/prisma'; +import { DocumentStatus } from '@documenso/prisma/client'; export type DeleteUserOptions = { email: string; @@ -31,7 +32,7 @@ export const deletedServiceAccount = async ({ email }: DeleteUserOptions) => { where: { userId: user.id, status: { - in: ['PENDING', 'COMPLETED'], + in: [DocumentStatus.PENDING, DocumentStatus.COMPLETED], }, }, data: { From 25291b64ebc61ce69387ee1e43511f58daccbf93 Mon Sep 17 00:00:00 2001 From: Sumit Bisht Date: Thu, 15 Feb 2024 22:25:23 +0530 Subject: [PATCH 100/466] fix: highlighting issue in recipient selection --- packages/ui/primitives/command.tsx | 2 +- packages/ui/primitives/document-flow/add-fields.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index fee5321cd..89777d417 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -121,7 +121,7 @@ const CommandItem = React.forwardRef< - + From 019db27b1d4b040cdbacf2df791f37efbd362478 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:04:11 +0200 Subject: [PATCH 101/466] feat: trigger webhook functionality --- .../document/complete-document-with-token.ts | 27 +++++++++---- .../server-only/document/create-document.ts | 12 +++++- .../server-only/webhooks/get-all-webhooks.ts | 17 ++++++++ .../lib/universal/post-webhook-payload.ts | 39 +++++++++++++++++++ packages/lib/universal/trigger-webhook.ts | 30 ++++++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 packages/lib/server-only/webhooks/get-all-webhooks.ts create mode 100644 packages/lib/universal/post-webhook-payload.ts create mode 100644 packages/lib/universal/trigger-webhook.ts diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 62db516fa..b1438f0ce 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -2,7 +2,9 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { WebhookTriggerEvents } from '@documenso/prisma/client'; +import { triggerWebhook } from '../../universal/trigger-webhook'; import { sealDocument } from './seal-document'; import { sendPendingEmail } from './send-pending-email'; @@ -11,13 +13,8 @@ export type CompleteDocumentWithTokenOptions = { documentId: number; }; -export const completeDocumentWithToken = async ({ - token, - documentId, -}: CompleteDocumentWithTokenOptions) => { - 'use server'; - - const document = await prisma.document.findFirstOrThrow({ +const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => { + return await prisma.document.findFirstOrThrow({ where: { id: documentId, Recipient: { @@ -34,6 +31,15 @@ export const completeDocumentWithToken = async ({ }, }, }); +}; + +export const completeDocumentWithToken = async ({ + token, + documentId, +}: CompleteDocumentWithTokenOptions) => { + 'use server'; + + const document = await getDocument({ token, documentId }); if (document.status === DocumentStatus.COMPLETED) { throw new Error(`Document ${document.id} has already been completed`); @@ -101,4 +107,11 @@ export const completeDocumentWithToken = async ({ if (documents.count > 0) { await sealDocument({ documentId: document.id }); } + + const updatedDocument = await getDocument({ token, documentId }); + + await triggerWebhook({ + eventTrigger: WebhookTriggerEvents.DOCUMENT_SIGNED, + documentData: updatedDocument, + }); }; diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index 93307a7b4..82dacfba7 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -1,6 +1,9 @@ 'use server'; import { prisma } from '@documenso/prisma'; +import { WebhookTriggerEvents } from '@documenso/prisma/client'; + +import { triggerWebhook } from '../../universal/trigger-webhook'; export type CreateDocumentOptions = { title: string; @@ -29,7 +32,7 @@ export const createDocument = async ({ }); } - return await tx.document.create({ + const createdDocument = await tx.document.create({ data: { title, documentDataId, @@ -37,5 +40,12 @@ export const createDocument = async ({ teamId, }, }); + + await triggerWebhook({ + eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED, + documentData: createdDocument, + }); + + return createdDocument; }); }; diff --git a/packages/lib/server-only/webhooks/get-all-webhooks.ts b/packages/lib/server-only/webhooks/get-all-webhooks.ts new file mode 100644 index 000000000..a6c88a086 --- /dev/null +++ b/packages/lib/server-only/webhooks/get-all-webhooks.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; +import type { WebhookTriggerEvents } from '@documenso/prisma/client'; + +export type GetAllWebhooksOptions = { + eventTrigger: WebhookTriggerEvents; +}; + +export const getAllWebhooks = async ({ eventTrigger }: GetAllWebhooksOptions) => { + return prisma.webhook.findMany({ + where: { + eventTriggers: { + has: eventTrigger, + }, + enabled: true, + }, + }); +}; diff --git a/packages/lib/universal/post-webhook-payload.ts b/packages/lib/universal/post-webhook-payload.ts new file mode 100644 index 000000000..80ddea80d --- /dev/null +++ b/packages/lib/universal/post-webhook-payload.ts @@ -0,0 +1,39 @@ +import type { Document, Webhook } from '@documenso/prisma/client'; + +export type PostWebhookPayloadOptions = { + webhookData: Pick; + documentData: Document; +}; + +export const postWebhookPayload = async ({ + webhookData, + documentData, +}: PostWebhookPayloadOptions) => { + const { webhookUrl, secret } = webhookData; + + const payload = { + event: webhookData.eventTriggers.toString(), + createdAt: new Date().toISOString(), + webhookEndpoint: webhookUrl, + payload: documentData, + }; + + const response = await fetch(webhookUrl, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + 'X-Documenso-Secret': secret ?? '', + }, + }); + + if (!response.ok) { + throw new Error(`Webhook failed with the status code ${response.status}`); + } + + return { + status: response.status, + statusText: response.statusText, + message: 'Webhook sent successfully', + }; +}; diff --git a/packages/lib/universal/trigger-webhook.ts b/packages/lib/universal/trigger-webhook.ts new file mode 100644 index 000000000..025a154bc --- /dev/null +++ b/packages/lib/universal/trigger-webhook.ts @@ -0,0 +1,30 @@ +import type { Document, WebhookTriggerEvents } from '@documenso/prisma/client'; + +import { getAllWebhooks } from '../server-only/webhooks/get-all-webhooks'; +import { postWebhookPayload } from './post-webhook-payload'; + +export type TriggerWebhookOptions = { + eventTrigger: WebhookTriggerEvents; + documentData: Document; +}; + +export const triggerWebhook = async ({ eventTrigger, documentData }: TriggerWebhookOptions) => { + try { + const allWebhooks = await getAllWebhooks({ eventTrigger }); + + const webhookPromises = allWebhooks.map((webhook) => { + const { webhookUrl, secret } = webhook; + + postWebhookPayload({ + webhookData: { webhookUrl, secret, eventTriggers: [eventTrigger] }, + documentData, + }).catch((_err) => { + throw new Error(`Failed to send webhook to ${webhookUrl}`); + }); + }); + + return Promise.all(webhookPromises); + } catch (err) { + throw new Error(`Failed to trigger webhook`); + } +}; From 7f3f6f531232619ee574734cca4b8a44c990f733 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 16 Feb 2024 11:44:03 +0200 Subject: [PATCH 102/466] feat: hide secret field --- .../src/app/(dashboard)/settings/webhooks/[id]/page.tsx | 3 ++- .../settings/webhooks/create-webhook-dialog.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx index 56a1e90a9..785536c2a 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -18,6 +18,7 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { Switch } from '@documenso/ui/primitives/switch'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -125,7 +126,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) { Secret - + diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 0e24b04a7..2d4dc733e 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -30,6 +30,7 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { Switch } from '@documenso/ui/primitives/switch'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -143,7 +144,11 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr Secret - + From a30b73ce86374ea0bfa9015c1633bc680f978f80 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 16 Feb 2024 11:02:04 +0000 Subject: [PATCH 103/466] fix: update css --- apps/web/src/app/(dashboard)/documents/upload-document.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index 71926dafc..f958d5a3f 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -96,12 +96,10 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { } }; - const isSmallVerticalScreen = typeof window !== 'undefined' && window.innerHeight < 800; - return (
    Date: Fri, 16 Feb 2024 13:44:28 +0200 Subject: [PATCH 104/466] chore: loading spinner --- .../src/app/(dashboard)/settings/webhooks/[id]/page.tsx | 8 +++++++- apps/web/src/app/(dashboard)/settings/webhooks/page.tsx | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx index 785536c2a..b9f04790c 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import type { z } from 'zod'; @@ -39,7 +40,7 @@ export default function WebhookPage({ params }: WebhookPageOptions) { const { toast } = useToast(); const router = useRouter(); - const { data: webhook } = trpc.webhook.getWebhookById.useQuery( + const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery( { id: Number(params.id), }, @@ -87,6 +88,11 @@ export default function WebhookPage({ params }: WebhookPageOptions) { title="Edit webhook" subtitle="On this page, you can edit the webhook and its settings." /> + {isLoading && ( +
    + +
    + )}
    diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index 638443bf9..d0532c065 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { Zap } from 'lucide-react'; import { ToggleLeft, ToggleRight } from 'lucide-react'; +import { Loader } from 'lucide-react'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -13,7 +14,7 @@ import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/ import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog'; export default function WebhookPage() { - const { data: webhooks } = trpc.webhook.getWebhooks.useQuery(); + const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery(); return (
    @@ -24,6 +25,12 @@ export default function WebhookPage() { + {isLoading && ( +
    + +
    + )} + {webhooks && webhooks.length === 0 && ( // TODO: Perhaps add some illustrations here to make the page more engaging
    From d83769b4104c9fbe4e74625486328712e16c4ca2 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 16 Feb 2024 11:56:02 +0000 Subject: [PATCH 105/466] chore: use unsafe effect --- .../lib/client-only/hooks/use-effect-once.ts | 13 ++++++++++ .../signature-pad/signature-pad.tsx | 25 ++++++++++++------- 2 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 packages/lib/client-only/hooks/use-effect-once.ts diff --git a/packages/lib/client-only/hooks/use-effect-once.ts b/packages/lib/client-only/hooks/use-effect-once.ts new file mode 100644 index 000000000..dc6d062dd --- /dev/null +++ b/packages/lib/client-only/hooks/use-effect-once.ts @@ -0,0 +1,13 @@ +import type { EffectCallback } from 'react'; +import { useEffect } from 'react'; + +/** + * Dangerously runs an effect "once" by ignoring the depedencies of a given effect. + * + * DANGER: The effect will run twice in concurrent react and development environments. + */ +export const unsafe_useEffectOnce = (callback: EffectCallback) => { + // Intentionally avoiding exhaustive deps and rule of hooks here + // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/rules-of-hooks + return useEffect(callback, []); +}; diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index ad6f92e91..8524450fc 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -7,6 +7,8 @@ import { Undo2 } from 'lucide-react'; import type { StrokeOptions } from 'perfect-freehand'; import { getStroke } from 'perfect-freehand'; +import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once'; + import { cn } from '../../lib/utils'; import { getSvgPathFromStroke } from './helper'; import { Point } from './point'; @@ -28,7 +30,8 @@ export const SignaturePad = ({ ...props }: SignaturePadProps) => { const $el = useRef(null); - const defaultImageRef = useRef(null); + const $imageData = useRef(null); + const [isPressed, setIsPressed] = useState(false); const [lines, setLines] = useState([]); const [currentLine, setCurrentLine] = useState([]); @@ -162,7 +165,7 @@ export const SignaturePad = ({ const ctx = $el.current.getContext('2d'); ctx?.clearRect(0, 0, $el.current.width, $el.current.height); - defaultImageRef.current = null; + $imageData.current = null; } onChange?.(null); @@ -176,8 +179,7 @@ export const SignaturePad = ({ return; } - const newLines = [...lines]; - newLines.pop(); // Remove the last line + const newLines = lines.slice(0, -1); setLines(newLines); // Clear the canvas @@ -185,13 +187,16 @@ export const SignaturePad = ({ const ctx = $el.current.getContext('2d'); const { width, height } = $el.current; ctx?.clearRect(0, 0, width, height); - if (typeof defaultValue === 'string' && defaultImageRef.current) { - ctx?.putImageData(defaultImageRef.current, 0, 0); + + if (typeof defaultValue === 'string' && $imageData.current) { + ctx?.putImageData($imageData.current, 0, 0); } + newLines.forEach((line) => { const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); ctx?.fill(pathData); }); + onChange?.($el.current.toDataURL()); } }; @@ -203,7 +208,7 @@ export const SignaturePad = ({ } }, []); - useEffect(() => { + unsafe_useEffectOnce(() => { if ($el.current && typeof defaultValue === 'string') { const ctx = $el.current.getContext('2d'); @@ -213,13 +218,15 @@ export const SignaturePad = ({ img.onload = () => { ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height)); + const defaultImageData = ctx?.getImageData(0, 0, width, height) || null; - defaultImageRef.current = defaultImageData; + + $imageData.current = defaultImageData; }; img.src = defaultValue; } - }, []); + }); return (
    Date: Fri, 16 Feb 2024 13:58:03 +0200 Subject: [PATCH 106/466] chore: ui updates --- apps/web/src/app/(dashboard)/settings/webhooks/page.tsx | 2 +- .../(dashboard)/settings/webhooks/create-webhook-dialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx index d0532c065..d36de6726 100644 --- a/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/webhooks/page.tsx @@ -65,7 +65,7 @@ export default function WebhookPage() { )}
    -
    +
    diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 2d4dc733e..c32493c4f 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -92,7 +92,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr {trigger ?? } - + Create webhook On this page, you can create a new webhook. From 5d6cdbef891b558900a0017aabe4c5090ee9b264 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Fri, 16 Feb 2024 20:46:27 +0000 Subject: [PATCH 107/466] feat: ability to download all the 2FA recovery codes --- .../forms/2fa/view-recovery-codes-dialog.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 18714332a..323bc7198 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; @@ -41,6 +41,7 @@ export type ViewRecoveryCodesDialogProps = { export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => { const { toast } = useToast(); + const [recoveryCodesUrl, setRecoveryCodesUrl] = useState(''); const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); @@ -62,6 +63,16 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode return 'view'; }, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]); + useEffect(() => { + if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) { + const textBlob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], { + type: 'text/plain', + }); + if (recoveryCodesUrl) URL.revokeObjectURL(recoveryCodesUrl); + setRecoveryCodesUrl(URL.createObjectURL(textBlob)); + } + }, [viewRecoveryCodesData]); + const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => { try { await viewRecoveryCodes({ password }); @@ -139,8 +150,11 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode )} -
    +
    + + +
    )) From 2815b1a8091f6b3dd1caecaac41c8da2a0828f1a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 17 Feb 2024 12:42:00 +1100 Subject: [PATCH 108/466] feat: add enterprise billing (#939) ## Description Add support for enterprise billing plans. Enterprise billing plans by default get access to everything early adopters do: - Unlimited teams - Unlimited documents They will also get additional features in the future. ## Notes Pending webhook updates to support enterprise onboarding. Rolled back env changes `NEXT_PUBLIC_PROJECT` since it doesn't seem to work. --- .../app/(dashboard)/settings/billing/page.tsx | 16 ++++++++-------- .../tables/teams-member-page-data-table.tsx | 2 +- packages/ee/server-only/limits/server.ts | 8 ++++---- .../stripe/get-document-related-prices.ts.ts | 10 ++++++++++ .../stripe/get-enterprise-plan-prices.ts | 13 +++++++++++++ .../ee/server-only/stripe/get-prices-by-plan.ts | 14 +++++++++----- .../stripe/get-primary-account-plan-prices.ts | 10 ++++++++++ .../stripe/get-team-related-prices.ts | 17 +++++++++++++++++ .../stripe/transfer-team-subscription.ts | 12 ++++++------ packages/lib/constants/app.ts | 9 ++++----- packages/lib/constants/billing.ts | 3 +-- packages/lib/server-only/team/create-team.ts | 13 ++++++------- packages/lib/utils/billing.ts | 9 ++++----- 13 files changed, 93 insertions(+), 43 deletions(-) create mode 100644 packages/ee/server-only/stripe/get-document-related-prices.ts.ts create mode 100644 packages/ee/server-only/stripe/get-enterprise-plan-prices.ts create mode 100644 packages/ee/server-only/stripe/get-primary-account-plan-prices.ts create mode 100644 packages/ee/server-only/stripe/get-team-related-prices.ts diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index cee2aa2f1..7865e2b5c 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -5,7 +5,7 @@ import { match } from 'ts-pattern'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; -import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan'; +import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices'; import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id'; import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; @@ -37,23 +37,23 @@ export default async function BillingSettingsPage() { user = await getStripeCustomerByUser(user).then((result) => result.user); } - const [subscriptions, prices, communityPlanPrices] = await Promise.all([ + const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([ getSubscriptionsByUserId({ userId: user.id }), getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }), - getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY), + getPrimaryAccountPlanPrices(), ]); - const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id); + const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id); let subscriptionProduct: Stripe.Product | null = null; - const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) => - communityPlanPriceIds.includes(priceId), + const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) => + primaryAccountPlanPriceIds.includes(priceId), ); const subscription = - communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? - communityPlanUserSubscriptions[0]; + primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? + primaryAccountPlanSubscriptions[0]; if (subscription?.priceId) { subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( diff --git a/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx index 316c4373f..24d4089b2 100644 --- a/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx +++ b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx @@ -67,7 +67,7 @@ export const TeamsMemberPageDataTable = ({ - All + Active diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index 4904f0271..abed86da7 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -1,11 +1,10 @@ import { DateTime } from 'luxon'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; -import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import { prisma } from '@documenso/prisma'; import { SubscriptionStatus } from '@documenso/prisma/client'; -import { getPricesByPlan } from '../stripe/get-prices-by-plan'; +import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants'; import { ERROR_CODES } from './errors'; import { ZLimitsSchema } from './schema'; @@ -56,10 +55,11 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => { ); if (activeSubscriptions.length > 0) { - const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY); + const documentPlanPrices = await getDocumentRelatedPrices(); for (const subscription of activeSubscriptions) { - const price = communityPlanPrices.find((price) => price.id === subscription.priceId); + const price = documentPlanPrices.find((price) => price.id === subscription.priceId); + if (!price || typeof price.product === 'string' || price.product.deleted) { continue; } diff --git a/packages/ee/server-only/stripe/get-document-related-prices.ts.ts b/packages/ee/server-only/stripe/get-document-related-prices.ts.ts new file mode 100644 index 000000000..81b32a7b9 --- /dev/null +++ b/packages/ee/server-only/stripe/get-document-related-prices.ts.ts @@ -0,0 +1,10 @@ +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; + +import { getPricesByPlan } from './get-prices-by-plan'; + +/** + * Returns the Stripe prices of items that affect the amount of documents a user can create. + */ +export const getDocumentRelatedPrices = async () => { + return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]); +}; diff --git a/packages/ee/server-only/stripe/get-enterprise-plan-prices.ts b/packages/ee/server-only/stripe/get-enterprise-plan-prices.ts new file mode 100644 index 000000000..ec67fe163 --- /dev/null +++ b/packages/ee/server-only/stripe/get-enterprise-plan-prices.ts @@ -0,0 +1,13 @@ +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; + +import { getPricesByPlan } from './get-prices-by-plan'; + +export const getEnterprisePlanPrices = async () => { + return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE); +}; + +export const getEnterprisePlanPriceIds = async () => { + const prices = await getEnterprisePlanPrices(); + + return prices.map((price) => price.id); +}; diff --git a/packages/ee/server-only/stripe/get-prices-by-plan.ts b/packages/ee/server-only/stripe/get-prices-by-plan.ts index 5c390b35a..45906d54a 100644 --- a/packages/ee/server-only/stripe/get-prices-by-plan.ts +++ b/packages/ee/server-only/stripe/get-prices-by-plan.ts @@ -1,14 +1,18 @@ import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import { stripe } from '@documenso/lib/server-only/stripe'; -export const getPricesByPlan = async ( - plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE], -) => { +type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE]; + +export const getPricesByPlan = async (plan: PlanType | PlanType[]) => { + const planTypes = typeof plan === 'string' ? [plan] : plan; + + const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR '); + const { data: prices } = await stripe.prices.search({ - query: `metadata['plan']:'${plan}' type:'recurring'`, + query, expand: ['data.product'], limit: 100, }); - return prices; + return prices.filter((price) => price.type === 'recurring'); }; diff --git a/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts b/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts new file mode 100644 index 000000000..0eb368ce7 --- /dev/null +++ b/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts @@ -0,0 +1,10 @@ +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; + +import { getPricesByPlan } from './get-prices-by-plan'; + +/** + * Returns the prices of items that count as the account's primary plan. + */ +export const getPrimaryAccountPlanPrices = async () => { + return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]); +}; diff --git a/packages/ee/server-only/stripe/get-team-related-prices.ts b/packages/ee/server-only/stripe/get-team-related-prices.ts new file mode 100644 index 000000000..b10ab06f4 --- /dev/null +++ b/packages/ee/server-only/stripe/get-team-related-prices.ts @@ -0,0 +1,17 @@ +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; + +import { getPricesByPlan } from './get-prices-by-plan'; + +/** + * Returns the Stripe prices of items that affect the amount of teams a user can create. + */ +export const getTeamRelatedPrices = async () => { + return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]); +}; + +/** + * Returns the Stripe price IDs of items that affect the amount of teams a user can create. + */ +export const getTeamRelatedPriceIds = async () => { + return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id)); +}; diff --git a/packages/ee/server-only/stripe/transfer-team-subscription.ts b/packages/ee/server-only/stripe/transfer-team-subscription.ts index b4e0bd59a..953efcaf4 100644 --- a/packages/ee/server-only/stripe/transfer-team-subscription.ts +++ b/packages/ee/server-only/stripe/transfer-team-subscription.ts @@ -2,13 +2,13 @@ import type Stripe from 'stripe'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { stripe } from '@documenso/lib/server-only/stripe'; -import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing'; +import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing'; import { prisma } from '@documenso/prisma'; import { type Subscription, type Team, type User } from '@documenso/prisma/client'; import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods'; -import { getCommunityPlanPriceIds } from './get-community-plan-prices'; import { getTeamPrices } from './get-team-prices'; +import { getTeamRelatedPriceIds } from './get-team-related-prices'; type TransferStripeSubscriptionOptions = { /** @@ -46,14 +46,14 @@ export const transferTeamSubscription = async ({ throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.'); } - const [communityPlanIds, teamSeatPrices] = await Promise.all([ - getCommunityPlanPriceIds(), + const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([ + getTeamRelatedPriceIds(), getTeamPrices(), ]); - const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan( + const teamSubscriptionRequired = !subscriptionsContainsActivePlan( user.Subscription, - communityPlanIds, + teamRelatedPlanPriceIds, ); let teamSubscription: Stripe.Subscription | null = null; diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index 1adb4effb..c17193677 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -3,18 +3,17 @@ import { env } from 'next-runtime-env'; export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50; -export const NEXT_PUBLIC_PROJECT = () => env('NEXT_PUBLIC_PROJECT'); export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL'); export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL'); -export const IS_APP_MARKETING = () => NEXT_PUBLIC_PROJECT() === 'marketing'; -export const IS_APP_WEB = () => NEXT_PUBLIC_PROJECT() === 'web'; +export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing'; +export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web'; export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true'; -export const APP_FOLDER = () => (IS_APP_MARKETING() ? 'marketing' : 'web'); +export const APP_FOLDER = () => (IS_APP_MARKETING ? 'marketing' : 'web'); export const APP_BASE_URL = () => - IS_APP_WEB() ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL(); + IS_APP_WEB ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL(); export const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'; export const MARKETING_BASE_URL = NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001'; diff --git a/packages/lib/constants/billing.ts b/packages/lib/constants/billing.ts index e6d897af8..0d8dee6e2 100644 --- a/packages/lib/constants/billing.ts +++ b/packages/lib/constants/billing.ts @@ -6,6 +6,5 @@ export enum STRIPE_CUSTOMER_TYPE { export enum STRIPE_PLAN_TYPE { TEAM = 'team', COMMUNITY = 'community', + ENTERPRISE = 'enterprise', } - -export const TEAM_BILLING_DOMAIN = 'billing.team.documenso.com'; diff --git a/packages/lib/server-only/team/create-team.ts b/packages/lib/server-only/team/create-team.ts index 4d26a161a..3461d49bf 100644 --- a/packages/lib/server-only/team/create-team.ts +++ b/packages/lib/server-only/team/create-team.ts @@ -2,11 +2,11 @@ import type Stripe from 'stripe'; import { z } from 'zod'; import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer'; -import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices'; +import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices'; import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing'; +import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing'; import { prisma } from '@documenso/prisma'; import { Prisma, TeamMemberRole } from '@documenso/prisma/client'; @@ -61,13 +61,12 @@ export const createTeam = async ({ let customerId: string | null = null; if (IS_BILLING_ENABLED()) { - const communityPlanPriceIds = await getCommunityPlanPriceIds(); - - isPaymentRequired = !subscriptionsContainsActiveCommunityPlan( - user.Subscription, - communityPlanPriceIds, + const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) => + prices.map((price) => price.id), ); + isPaymentRequired = !subscriptionsContainsActivePlan(user.Subscription, teamRelatedPriceIds); + customerId = await createTeamCustomer({ name: user.name ?? teamName, email: user.email, diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts index ca85addbb..048fa6ee0 100644 --- a/packages/lib/utils/billing.ts +++ b/packages/lib/utils/billing.ts @@ -2,15 +2,14 @@ import type { Subscription } from '.prisma/client'; import { SubscriptionStatus } from '.prisma/client'; /** - * Returns true if there is a subscription that is active and is a community plan. + * Returns true if there is a subscription that is active and is one of the provided price IDs. */ -export const subscriptionsContainsActiveCommunityPlan = ( +export const subscriptionsContainsActivePlan = ( subscriptions: Subscription[], - communityPlanPriceIds: string[], + priceIds: string[], ) => { return subscriptions.some( (subscription) => - subscription.status === SubscriptionStatus.ACTIVE && - communityPlanPriceIds.includes(subscription.priceId), + subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId), ); }; From f98567ea87852a77040488982f2d10ecf0dfc243 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sat, 17 Feb 2024 07:34:21 +0000 Subject: [PATCH 109/466] feat: request usee to disable 2fa before deleting account --- apps/web/src/components/forms/profile.tsx | 138 ++++++------------ .../lib/server-only/2fa/verify-2fa-token.ts | 1 - 2 files changed, 45 insertions(+), 94 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 23861c9fc..8a7e2ff3f 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -7,7 +7,6 @@ import { signOut } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa'; import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; @@ -67,13 +66,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { resolver: zodResolver(ZProfileFormSchema), }); - const deleteAccountTwoFactorTokenForm = useForm({ - defaultValues: { - token: '', - }, - resolver: zodResolver(ZTwoFactorAuthTokenSchema), - }); - const isSubmitting = form.formState.isSubmitting; const hasTwoFactorAuthentication = user.twoFactorEnabled; @@ -113,38 +105,17 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; - const deleteAccoutAndSignOut = async () => { - await deleteAccount(); - - toast({ - title: 'Account deleted', - description: 'Your account has been deleted successfully.', - duration: 5000, - }); - - return await signOut({ callbackUrl: '/' }); - }; - - const onDeleteAccount = async (hasTwoFactorAuthentication: boolean) => { + const onDeleteAccount = async () => { try { - if (!hasTwoFactorAuthentication) { - return await deleteAccoutAndSignOut(); - } + await deleteAccount(); - const { token } = deleteAccountTwoFactorTokenForm.getValues(); - - if (!token) { - throw new Error('Please enter your Two Factor Authentication token.'); - } - - await validateTwoFactorAuthentication({ - totpCode: token, - user, - }).catch(() => { - throw new Error('We were unable to validate your Two Factor Authentication token.'); + toast({ + title: 'Account deleted', + description: 'Your account has been deleted successfully.', + duration: 5000, }); - await deleteAccoutAndSignOut(); + return await signOut({ callbackUrl: '/' }); } catch (err) { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({ @@ -225,66 +196,47 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { irreversible and will cancel your subscription, so proceed with caution. - - { - console.log('delete account'); - })} - > - - - - - - - Delete Account - - Documenso will delete{' '} - all of your documents, along with all - of your completed documents, signatures, and all other resources belonging - to your Account. - - + + + + + + + Delete Account + + Documenso will delete{' '} + all of your documents, along with all of + your completed documents, signatures, and all other resources belonging to your + Account. + + - - - This action is not reversible. Please be certain. - - + + + This action is not reversible. Please be certain. + + - {hasTwoFactorAuthentication && ( -
    - ( - - - Two Factor Authentication Token - - - - - - - )} - /> -
    - )} + {hasTwoFactorAuthentication && ( + + + Disable Two Factor Authentication before deleting your account. + + + )} - - - -
    -
    - - + + + +
    +
    diff --git a/packages/lib/server-only/2fa/verify-2fa-token.ts b/packages/lib/server-only/2fa/verify-2fa-token.ts index 3c410bd58..0e8ec6afc 100644 --- a/packages/lib/server-only/2fa/verify-2fa-token.ts +++ b/packages/lib/server-only/2fa/verify-2fa-token.ts @@ -17,7 +17,6 @@ export const verifyTwoFactorAuthenticationToken = async ({ user, totpCode, }: VerifyTwoFactorAuthenticationTokenOptions) => { - // TODO: This is undefined and I can't figure out why. const key = DOCUMENSO_ENCRYPTION_KEY; if (!user.twoFactorSecret) { From 0186f2dfeda8240be2bbf9e46e158f81d70e3321 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Sat, 17 Feb 2024 13:19:03 +0530 Subject: [PATCH 110/466] feat: ability to download 2FA recovery codes --- .../web/src/components/forms/2fa/view-recovery-codes-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 323bc7198..cfdae7015 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -153,7 +153,7 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
    - +
    From 5687503dfc8a63b7492489af13b8fb1b813edfa8 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sat, 17 Feb 2024 08:26:30 +0000 Subject: [PATCH 111/466] feat: sign document with a custom text --- .../src/app/(signing)/sign/[token]/page.tsx | 4 + .../app/(signing)/sign/[token]/provider.tsx | 7 + .../app/(signing)/sign/[token]/text-field.tsx | 178 ++++++++++++++++++ .../primitives/document-flow/add-fields.tsx | 22 +++ 4 files changed, 211 insertions(+) create mode 100644 apps/web/src/app/(signing)/sign/[token]/text-field.tsx diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 99b9d1dd7..83cdb93e2 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -29,6 +29,7 @@ import { NameField } from './name-field'; import { NoLongerAvailable } from './no-longer-available'; import { SigningProvider } from './provider'; import { SignatureField } from './signature-field'; +import { TextField } from './text-field'; export type SigningPageProps = { params: { @@ -168,6 +169,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp .with(FieldType.EMAIL, () => ( )) + .with(FieldType.TEXT, () => ( + + )) .otherwise(() => null), )} diff --git a/apps/web/src/app/(signing)/sign/[token]/provider.tsx b/apps/web/src/app/(signing)/sign/[token]/provider.tsx index 454007cb0..6531e8a40 100644 --- a/apps/web/src/app/(signing)/sign/[token]/provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/provider.tsx @@ -9,6 +9,8 @@ export type SigningContextValue = { setEmail: (_value: string) => void; signature: string | null; setSignature: (_value: string | null) => void; + customText: string; + setCustomText: (_value: string) => void; }; const SigningContext = createContext(null); @@ -31,6 +33,7 @@ export interface SigningProviderProps { fullName?: string | null; email?: string | null; signature?: string | null; + customText?: string | null; children: React.ReactNode; } @@ -38,11 +41,13 @@ export const SigningProvider = ({ fullName: initialFullName, email: initialEmail, signature: initialSignature, + customText: initialCustomText, children, }: SigningProviderProps) => { const [fullName, setFullName] = useState(initialFullName || ''); const [email, setEmail] = useState(initialEmail || ''); const [signature, setSignature] = useState(initialSignature || null); + const [customText, setCustomText] = useState(initialCustomText || ''); return ( {children} diff --git a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx new file mode 100644 index 000000000..d324a50ba --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useEffect, useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; +import { Label } from '@documenso/ui/primitives/label'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredSigningContext } from './provider'; +import { SigningFieldContainer } from './signing-field-container'; + +export type TextFieldProps = { + field: FieldWithSignature; + recipient: Recipient; +}; + +export const TextField = ({ field, recipient }: TextFieldProps) => { + const router = useRouter(); + + const { toast } = useToast(); + const { customText: providedCustomText, setCustomText: setProvidedCustomText } = + useRequiredSigningContext(); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const [showCustomTextModal, setShowCustomTextModal] = useState(false); + const [localText, setLocalCustomText] = useState(''); + const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false); + + useEffect(() => { + if (!showCustomTextModal && !isLocalSignatureSet) { + setLocalCustomText(''); + } + }, [showCustomTextModal, isLocalSignatureSet]); + + const onSign = async (source: 'local' | 'provider' = 'provider') => { + try { + if (!providedCustomText && !localText) { + setIsLocalSignatureSet(false); + setShowCustomTextModal(true); + return; + } + + const value = source === 'local' && localText ? localText : providedCustomText ?? ''; + + if (!value) { + return; + } + + await signFieldWithToken({ + token: recipient.token, + fieldId: field.id, + value, + isBase64: true, + }); + + if (source === 'local' && !providedCustomText) { + setProvidedCustomText(localText); + } + + setLocalCustomText(''); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + // Necessary to reset the custom text if the user removes the signature + setProvidedCustomText(''); + + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the signature.', + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( +
    + +
    + )} + + {!field.inserted && ( +

    Text

    + )} + + {field.inserted &&

    {field.customText}

    } + + + + + Enter a Text ({recipient.email}) + + +
    + + +