From 24d9906557ed7ed5644f4069da591c1b143a24a7 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 22 Nov 2023 15:03:15 +0200 Subject: [PATCH 01/76] 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 02/76] 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 03/76] 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 04/76] 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 05/76] 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 06/76] 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 07/76] 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 10/76] 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 11/76] 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 12/76] 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 13/76] 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 14/76] 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 15/76] 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 16/76] 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 17/76] 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 18/76] 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 19/76] 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 20/76] 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 21/76] 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 22/76] 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 23/76] 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/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 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 28/76] 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 32/76] 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 33/76] 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 34/76] 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 5a28eaa4ff6477fda00776931e2076783c08d7d5 Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 22 Jan 2024 17:38:02 +1100 Subject: [PATCH 35/76] 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 014c09bd910a407f974dff880e1c35e10e02ece8 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sun, 28 Jan 2024 18:43:20 +0000 Subject: [PATCH 36/76] 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 40/76] 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 98df273ebc62d2c98508da4965dba04e0f9ed0c8 Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 29 Jan 2024 22:53:15 +1100 Subject: [PATCH 41/76] 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 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 42/76] 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); + }} + /> + + + + )} + /> + + +
- - - - 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 5687503dfc8a63b7492489af13b8fb1b813edfa8 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sat, 17 Feb 2024 08:26:30 +0000 Subject: [PATCH 50/76] 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}) + + +
+ + +