From 24d9906557ed7ed5644f4069da591c1b143a24a7 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 22 Nov 2023 15:03:15 +0200 Subject: [PATCH 01/42] 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 f7d0e2a5dc..f1078e5288 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 0000000000..194b663f7f --- /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 b003509aad..f92b04e4cd 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 4aefcb98c1..dc21318a7f 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/42] 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 0000000000..872d68e28c --- /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 0000000000..bbc3ab14c2 --- /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 194b663f7f..95362c809a 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 0000000000..0d66cda1f5 --- /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/42] 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 95362c809a..976dce5fcd 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/42] 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 872d68e28c..27429bdc99 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 976dce5fcd..5357e67fa6 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/42] 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 0000000000..d3c9106c49 --- /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 02807e4a07..8e073829bc 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/42] 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 5357e67fa6..2a002db45a 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/42] 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 0000000000..e868df47d0 --- /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 901c6a5ae9..89bcabf60a 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 0000000000..c6c7a7d947 --- /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 88f3ad9d0c..49dac88097 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/42] 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 e868df47d0..ab4992f14e 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 8e9d9d2b68..0daacf0607 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 49dac88097..266c045d02 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/42] 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 0000000000..d73a3a786f --- /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 0daacf0607..131e90a7d7 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/42] 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 d73a3a786f..9a60bd60b8 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 131e90a7d7..a92321e6b2 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 c6c7a7d947..d64562b83f 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/42] 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 9a60bd60b8..f847cd7933 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 a92321e6b2..85d6cc038a 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/42] 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 85d6cc038a..954d4dd2e9 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/42] 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 27429bdc99..ee2e5b9346 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 user = await validateUserToken(authorization); - const { documents, totalPages } = await getDocuments({ page, perPage }); + 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 bbc3ab14c2..deea612e86 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 0000000000..3092deaa7c --- /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 2a002db45a..1a15e5fd02 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/42] 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 ee2e5b9346..1c915da309 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 72bca27717..f54eb40ca6 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 2e708363f7..3f18069881 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 1a15e5fd02..3dd5142cba 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 fb32bcdf39..69aba91160 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/42] 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 1c915da309..b8e36340bf 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 f54eb40ca6..d244df9e8b 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 69aba91160..fb32bcdf39 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/42] 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 3dd5142cba..8e8f7b9bd6 100644 --- a/packages/trpc/api-contract/contract.ts +++ b/packages/trpc/api-contract/contract.ts @@ -1,48 +1,19 @@ import { initContract } from '@ts-rest/core'; -import { z } from 'zod'; -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(), -}); +import { + AuthorizationHeadersSchema, + CreateDocumentMutationSchema, + DeleteDocumentMutationSchema, + GetDocumentsQuerySchema, + SendDocumentForSigningMutationSchema, + SuccessfulDocumentResponseSchema, + SuccessfulResponseSchema, + SuccessfulSigningResponseSchema, + UnsuccessfulResponseSchema, + UploadDocumentSuccessfulSchema, +} from './schema'; -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(), -}); +const c = initContract(); export const contract = c.router( { @@ -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', }, - deleteDocument: { - method: 'DELETE', - path: `/documents/:id`, - body: z.string(), + createDocument: { + method: 'POST', + path: '/documents', + body: CreateDocumentMutationSchema, responses: { - 200: DocumentSchema, + 200: UploadDocumentSuccessfulSchema, 401: UnsuccessfulResponseSchema, 404: UnsuccessfulResponseSchema, }, - summary: 'Delete a document', + summary: 'Upload a new document and get a presigned URL', }, - createDocument: { - method: 'POST', - path: '/documents', - contentType: 'multipart/form-data', - body: c.type<{ file: File }>(), + sendDocumentForSigning: { + method: 'PATCH', + path: '/documents/:id/send-for-signing', + body: SendDocumentForSigningMutationSchema, responses: { - 200: UploadDocumentSuccessfulSchema, + 200: SuccessfulSigningResponseSchema, + 400: UnsuccessfulResponseSchema, + 401: UnsuccessfulResponseSchema, + 404: UnsuccessfulResponseSchema, + }, + summary: 'Send a document for signing', + }, + deleteDocument: { + method: 'DELETE', + path: `/documents/:id`, + body: DeleteDocumentMutationSchema, + responses: { + 200: SuccessfulDocumentResponseSchema, 401: UnsuccessfulResponseSchema, - 500: UnsuccessfulResponseSchema, + 404: UnsuccessfulResponseSchema, }, - summary: 'Upload a new document', + summary: 'Delete a 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 0000000000..504fa55b20 --- /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/42] 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 214c5af997..848ff17cc0 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 ce61ba97d0..7482c2f105 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 b8e36340bf..96e1584a16 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, { }; } - try { - const regexPattern = /filename="(.+?)"/; - const match = body.toString().match(regexPattern); - const documentTitle = match?.[1] ?? 'Untitled document'; + const document = await getDocumentById({ id: Number(id), userId: user.id }); - const file = new Blob([body], { - type: 'application/pdf', - }); + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } - const { type, data } = await putFile(file); + if (document.status === 'PENDING') { + return { + status: 400, + body: { + message: 'Document is already waiting for signing', + }, + }; + } - const { id: documentDataId } = await createDocumentData({ - type, - data, + try { + await setRecipientsForDocument({ + userId: user.id, + documentId: Number(id), + recipients: [ + { + email: body.signerEmail, + name: body.signerName ?? '', + }, + ], }); - const { id } = await createDocument({ - title: documentTitle, - documentDataId, + 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: { - 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 af176063ff..398288006d 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 deea612e86..0000000000 --- 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 504fa55b20..d62d50d523 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 266c045d02..bae0944566 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/42] 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 ab4992f14e..889e7a2a80 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 954d4dd2e9..f3f05028e5 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 e2581e14cd..c1a2620484 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 582053359d..645e9fb1b8 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 3092deaa7c..277fc13b2a 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/42] 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 f3f05028e5..56b91b467d 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 277fc13b2a..5e696521c3 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 7d049df0da..1dbc89426e 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/42] 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 f847cd7933..1e3513d98b 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 56b91b467d..9091b7501b 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 1dbc89426e..7d049df0da 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/42] 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 1e3513d98b..b3f57018b7 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 b3f57018b7..ba0a4cc993 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 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 0b22d97c67..15b618ebdc 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 d244df9e8b..148b020964 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 0000000000..cb0ff5c3b5 --- /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 0000000000..5ac5aab455 --- /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 0000000000..9aea9b26f1 --- /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 0000000000..dc21318a7f --- /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 0000000000..0f853a0204 --- /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 0000000000..b317e95d61 --- /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 0000000000..3e23029a56 --- /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 0000000000..f4c80ca73f --- /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 d64562b83f..1ba31a6cf7 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 b615ef3af8..c28920b9a3 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/42] 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 e488ba6e99..7c06557b13 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 848ff17cc0..8bc395121b 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) => { - + - + - + Please enter a meaningful name for your token. This will help you identify it later. @@ -149,6 +166,65 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { )} /> + ( + + Token expiration date + +
+ + + +
+ + +
+ )} + /> + + ( + + Never expire + + { + setNoExpirationDate((prev) => !prev); + field.onChange(val); + }} + /> + + + + )} + /> + + +
+ +
+ + + ))} + + )} + + ); +} diff --git a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx index 40c797742f..3e7040baf9 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 @@ -32,12 +32,18 @@ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type DeleteTokenDialogProps = { + teamId?: number; token: Pick; onDelete?: () => void; children?: React.ReactNode; }; -export default function DeleteTokenDialog({ token, onDelete, children }: DeleteTokenDialogProps) { +export default function DeleteTokenDialog({ + teamId, + token, + onDelete, + children, +}: DeleteTokenDialogProps) { const router = useRouter(); const { toast } = useToast(); @@ -70,6 +76,7 @@ export default function DeleteTokenDialog({ token, onDelete, children }: DeleteT try { await deleteTokenMutation({ id: token.id, + teamId, }); toast({ diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx index 98df7416e7..20fe8cb2e9 100644 --- a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx @@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { useParams, usePathname } from 'next/navigation'; -import { CreditCard, Settings, Users } from 'lucide-react'; +import { Braces, CreditCard, Settings, Users } from 'lucide-react'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { cn } from '@documenso/ui/lib/utils'; @@ -21,6 +21,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { const settingsPath = `/t/${teamUrl}/settings`; const membersPath = `/t/${teamUrl}/settings/members`; + const tokensPath = `/t/${teamUrl}/settings/tokens`; const billingPath = `/t/${teamUrl}/settings/billing`; return ( @@ -48,6 +49,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + + + + {IS_BILLING_ENABLED() && ( + + + + {IS_BILLING_ENABLED() && (