mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
chore: merged main
This commit is contained in:
@ -2,6 +2,11 @@
|
|||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="secret"
|
NEXTAUTH_SECRET="secret"
|
||||||
|
|
||||||
|
# [[CRYPTO]]
|
||||||
|
# Application Key for symmetric encryption and decryption
|
||||||
|
# This should be a random string of at least 32 characters
|
||||||
|
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||||
|
|
||||||
# [[AUTH OPTIONAL]]
|
# [[AUTH OPTIONAL]]
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
|||||||
31
.github/workflows/e2e-tests.yml
vendored
31
.github/workflows/e2e-tests.yml
vendored
@ -8,19 +8,6 @@ jobs:
|
|||||||
e2e_tests:
|
e2e_tests:
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
@ -28,24 +15,34 @@ jobs:
|
|||||||
node-version: 18
|
node-version: 18
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Copy env
|
- name: Copy env
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Start Services
|
||||||
|
run: npm run dx:up
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
- name: Generate Prisma Client
|
||||||
run: npm run prisma:generate -w @documenso/prisma
|
run: npm run prisma:generate -w @documenso/prisma
|
||||||
|
|
||||||
- name: Create the database
|
- name: Create the database
|
||||||
run: npm run prisma:migrate-dev
|
run: npm run prisma:migrate-dev
|
||||||
|
|
||||||
|
- name: Seed the database
|
||||||
|
run: npm run prisma:seed
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: npm run ci
|
run: npm run ci
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: test-results
|
||||||
path: playwright-report/
|
path: "packages/app-tests/**/test-results/*"
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
env:
|
env:
|
||||||
NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
|
||||||
NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -6,5 +6,8 @@
|
|||||||
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"javascript.preferences.useAliasesForRenames": false,
|
"javascript.preferences.useAliasesForRenames": false,
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"files.eol": "\n",
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.insertSpaces": true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -240,7 +240,7 @@ Now you can install the dependencies and build it:
|
|||||||
|
|
||||||
```
|
```
|
||||||
npm i
|
npm i
|
||||||
npm run:build:web
|
npm run build:web
|
||||||
npm run prisma:migrate-deploy
|
npm run prisma:migrate-deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
13
apps/marketing/content/careers.mdx
Normal file
13
apps/marketing/content/careers.mdx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
title: Careers at Documenso
|
||||||
|
---
|
||||||
|
|
||||||
|
# Careers at Documenso
|
||||||
|
|
||||||
|
So you love Documenso and all the things that we do and now you want to work with us to unlock the future of open signing?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Positions
|
||||||
|
|
||||||
|
Unfortunately we have no open positions available at the moment. Our team has grown and so we must grow with it, please check back from time to time as now is not forever and we may be hiring again in the future.
|
||||||
@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { withContentlayer } = require('next-contentlayer');
|
const { withContentlayer } = require('next-contentlayer');
|
||||||
|
|
||||||
@ -10,16 +11,31 @@ ENV_FILES.forEach((file) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// !: This is a temp hack to get caveat working without placing it back in the public directory.
|
||||||
|
// !: By inlining this at build time we should be able to sign faster.
|
||||||
|
const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActionsBodySizeLimit: '10mb',
|
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '50mb',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
transpilePackages: [
|
||||||
|
'@documenso/assets',
|
||||||
|
'@documenso/lib',
|
||||||
|
'@documenso/tailwind-config',
|
||||||
|
'@documenso/trpc',
|
||||||
|
'@documenso/ui',
|
||||||
|
],
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_PROJECT: 'marketing',
|
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||||
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/marketing",
|
"name": "@documenso/marketing",
|
||||||
"version": "0.1.0",
|
"version": "1.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -24,8 +24,8 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.0",
|
"next": "14.0.3",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.5",
|
||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
@ -44,5 +44,13 @@
|
|||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"next-auth": {
|
||||||
|
"next": "$next"
|
||||||
|
},
|
||||||
|
"next-contentlayer": {
|
||||||
|
"next": "$next"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -8,23 +8,23 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||||
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainer,
|
DocumentFlowFormContainer,
|
||||||
DocumentFlowFormContainerHeader,
|
DocumentFlowFormContainerHeader,
|
||||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
|
||||||
|
|
||||||
type SinglePlayerModeStep = 'fields' | 'sign';
|
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||||
|
|
||||||
// !: This entire file is a hack to get around failed prerendering of
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
@ -41,6 +41,9 @@ export const SinglePlayerClient = () => {
|
|||||||
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
||||||
const [fields, setFields] = useState<Field[]>([]);
|
const [fields, setFields] = useState<Field[]>([]);
|
||||||
|
|
||||||
|
const { mutateAsync: createSinglePlayerDocument } =
|
||||||
|
trpc.singleplayer.createSinglePlayerDocument.useMutation();
|
||||||
|
|
||||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add document',
|
title: 'Add document',
|
||||||
|
|||||||
@ -31,6 +31,7 @@ const FOOTER_LINKS = [
|
|||||||
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
|
||||||
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
|
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
|
||||||
{ href: '/oss-friends', text: 'OSS Friends' },
|
{ href: '/oss-friends', text: 'OSS Friends' },
|
||||||
|
{ href: '/careers', text: 'Careers' },
|
||||||
{ href: '/privacy', text: 'Privacy' },
|
{ href: '/privacy', text: 'Privacy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { createElement } from 'react';
|
|
||||||
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { PDFDocument } from 'pdf-lib';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { mailer } from '@documenso/email/mailer';
|
|
||||||
import { render } from '@documenso/email/render';
|
|
||||||
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
|
||||||
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
|
||||||
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
|
||||||
import { alphaid } from '@documenso/lib/universal/id';
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import {
|
|
||||||
DocumentDataType,
|
|
||||||
DocumentStatus,
|
|
||||||
FieldType,
|
|
||||||
Prisma,
|
|
||||||
ReadStatus,
|
|
||||||
SendStatus,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@documenso/prisma/client';
|
|
||||||
import { signPdf } from '@documenso/signing';
|
|
||||||
|
|
||||||
const ZCreateSinglePlayerDocumentSchema = z.object({
|
|
||||||
documentData: z.object({
|
|
||||||
data: z.string(),
|
|
||||||
type: z.nativeEnum(DocumentDataType),
|
|
||||||
}),
|
|
||||||
documentName: z.string(),
|
|
||||||
signer: z.object({
|
|
||||||
email: z.string().email().min(1),
|
|
||||||
name: z.string(),
|
|
||||||
signature: z.string(),
|
|
||||||
}),
|
|
||||||
fields: z.array(
|
|
||||||
z.object({
|
|
||||||
page: z.number(),
|
|
||||||
type: z.nativeEnum(FieldType),
|
|
||||||
positionX: z.number(),
|
|
||||||
positionY: z.number(),
|
|
||||||
width: z.number(),
|
|
||||||
height: z.number(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and self signs a document.
|
|
||||||
*
|
|
||||||
* Returns the document token.
|
|
||||||
*/
|
|
||||||
export const createSinglePlayerDocument = async (
|
|
||||||
value: TCreateSinglePlayerDocumentSchema,
|
|
||||||
): Promise<string> => {
|
|
||||||
const { signer, fields, documentData, documentName } =
|
|
||||||
ZCreateSinglePlayerDocumentSchema.parse(value);
|
|
||||||
|
|
||||||
const document = await getFile({
|
|
||||||
data: documentData.data,
|
|
||||||
type: documentData.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const doc = await PDFDocument.load(document);
|
|
||||||
const createdAt = new Date();
|
|
||||||
|
|
||||||
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
|
||||||
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
|
|
||||||
const typedSignature = !isBase64 ? signer.signature : null;
|
|
||||||
|
|
||||||
// Update the document with the fields inserted.
|
|
||||||
for (const field of fields) {
|
|
||||||
const isSignatureField = field.type === FieldType.SIGNATURE;
|
|
||||||
|
|
||||||
await insertFieldInPDF(doc, {
|
|
||||||
...mapField(field, signer),
|
|
||||||
Signature: isSignatureField
|
|
||||||
? {
|
|
||||||
created: createdAt,
|
|
||||||
signatureImageAsBase64,
|
|
||||||
typedSignature,
|
|
||||||
// Dummy data.
|
|
||||||
id: -1,
|
|
||||||
recipientId: -1,
|
|
||||||
fieldId: -1,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
// Dummy data.
|
|
||||||
id: -1,
|
|
||||||
documentId: -1,
|
|
||||||
recipientId: -1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsignedPdfBytes = await doc.save();
|
|
||||||
|
|
||||||
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
|
|
||||||
|
|
||||||
const { token } = await prisma.$transaction(
|
|
||||||
async (tx) => {
|
|
||||||
const token = alphaid();
|
|
||||||
|
|
||||||
// Fetch service user who will be the owner of the document.
|
|
||||||
const serviceUser = await tx.user.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
email: SERVICE_USER_EMAIL,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { id: documentDataId } = await putFile({
|
|
||||||
name: `${documentName}.pdf`,
|
|
||||||
type: 'application/pdf',
|
|
||||||
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create document.
|
|
||||||
const document = await tx.document.create({
|
|
||||||
data: {
|
|
||||||
title: documentName,
|
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
documentDataId,
|
|
||||||
userId: serviceUser.id,
|
|
||||||
createdAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create recipient.
|
|
||||||
const recipient = await tx.recipient.create({
|
|
||||||
data: {
|
|
||||||
documentId: document.id,
|
|
||||||
name: signer.name,
|
|
||||||
email: signer.email,
|
|
||||||
token,
|
|
||||||
signedAt: createdAt,
|
|
||||||
readStatus: ReadStatus.OPENED,
|
|
||||||
signingStatus: SigningStatus.SIGNED,
|
|
||||||
sendStatus: SendStatus.SENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create fields and signatures.
|
|
||||||
await Promise.all(
|
|
||||||
fields.map(async (field) => {
|
|
||||||
const insertedField = await tx.field.create({
|
|
||||||
data: {
|
|
||||||
documentId: document.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
...mapField(field, signer),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
|
||||||
await tx.signature.create({
|
|
||||||
data: {
|
|
||||||
fieldId: insertedField.id,
|
|
||||||
signatureImageAsBase64,
|
|
||||||
typedSignature,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { document, token };
|
|
||||||
},
|
|
||||||
{
|
|
||||||
maxWait: 5000,
|
|
||||||
timeout: 30000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const template = createElement(DocumentSelfSignedEmailTemplate, {
|
|
||||||
documentName: documentName,
|
|
||||||
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send email to signer.
|
|
||||||
await mailer.sendMail({
|
|
||||||
to: {
|
|
||||||
address: signer.email,
|
|
||||||
name: signer.name,
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
name: FROM_NAME,
|
|
||||||
address: FROM_ADDRESS,
|
|
||||||
},
|
|
||||||
subject: 'Document signed',
|
|
||||||
html: render(template),
|
|
||||||
text: render(template, { plainText: true }),
|
|
||||||
attachments: [{ content: signedPdfBuffer, filename: documentName }],
|
|
||||||
});
|
|
||||||
|
|
||||||
return token;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map the fields provided by the user to fields compatible with Prisma.
|
|
||||||
*
|
|
||||||
* Signature fields are handled separately.
|
|
||||||
*
|
|
||||||
* @param field The field passed in by the user.
|
|
||||||
* @param signer The details of the person who is signing this document.
|
|
||||||
* @returns A field compatible with Prisma.
|
|
||||||
*/
|
|
||||||
const mapField = (
|
|
||||||
field: TCreateSinglePlayerDocumentSchema['fields'][number],
|
|
||||||
signer: TCreateSinglePlayerDocumentSchema['signer'],
|
|
||||||
) => {
|
|
||||||
const customText = match(field.type)
|
|
||||||
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
|
|
||||||
.with(FieldType.EMAIL, () => signer.email)
|
|
||||||
.with(FieldType.NAME, () => signer.name)
|
|
||||||
.otherwise(() => '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: field.type,
|
|
||||||
page: field.page,
|
|
||||||
positionX: new Prisma.Decimal(field.positionX),
|
|
||||||
positionY: new Prisma.Decimal(field.positionY),
|
|
||||||
width: new Prisma.Decimal(field.width),
|
|
||||||
height: new Prisma.Decimal(field.height),
|
|
||||||
customText,
|
|
||||||
inserted: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -2,6 +2,10 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next';
|
|||||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||||
import { appRouter } from '@documenso/trpc/server/router';
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
maxDuration: 60,
|
||||||
|
};
|
||||||
|
|
||||||
export default trpcNext.createNextApiHandler({
|
export default trpcNext.createNextApiHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { version } = require('./package.json');
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
@ -10,24 +11,35 @@ ENV_FILES.forEach((file) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// !: This is a temp hack to get caveat working without placing it back in the public directory.
|
||||||
|
// !: By inlining this at build time we should be able to sign faster.
|
||||||
|
const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
serverActionsBodySizeLimit: '50mb',
|
serverActions: {
|
||||||
|
bodySizeLimit: '50mb',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
|
'@documenso/assets',
|
||||||
|
'@documenso/ee',
|
||||||
'@documenso/lib',
|
'@documenso/lib',
|
||||||
'@documenso/prisma',
|
'@documenso/prisma',
|
||||||
|
'@documenso/tailwind-config',
|
||||||
'@documenso/trpc',
|
'@documenso/trpc',
|
||||||
'@documenso/ui',
|
'@documenso/ui',
|
||||||
'@documenso/email',
|
|
||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
APP_VERSION: version,
|
APP_VERSION: version,
|
||||||
NEXT_PUBLIC_PROJECT: 'web',
|
NEXT_PUBLIC_PROJECT: 'web',
|
||||||
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/web",
|
"name": "@documenso/web",
|
||||||
"version": "0.1.0",
|
"version": "1.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -8,6 +8,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"e2e:prepare": "next build && next start",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules",
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
@ -27,8 +28,8 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.0",
|
"next": "14.0.3",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.5",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"perfect-freehand": "^1.2.0",
|
"perfect-freehand": "^1.2.0",
|
||||||
@ -44,6 +45,7 @@
|
|||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -52,5 +54,13 @@
|
|||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"next-auth": {
|
||||||
|
"next": "$next"
|
||||||
|
},
|
||||||
|
"next-contentlayer": {
|
||||||
|
"next": "$next"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,16 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
||||||
|
|
||||||
export async function search(search: string, page: number, perPage: number) {
|
export async function search(search: string, page: number, perPage: number) {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
if (!isAdmin(user)) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
const results = await findUsers({ username: search, email: search, page, perPage });
|
const results = await findUsers({ username: search, email: search, page, perPage });
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@ -4,28 +4,28 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
|
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
|
||||||
|
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainer,
|
DocumentFlowFormContainer,
|
||||||
DocumentFlowFormContainerHeader,
|
DocumentFlowFormContainerHeader,
|
||||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { addFields } from '~/components/forms/edit-document/add-fields.action';
|
|
||||||
import { addSigners } from '~/components/forms/edit-document/add-signers.action';
|
|
||||||
import { completeDocument } from '~/components/forms/edit-document/add-subject.action';
|
|
||||||
|
|
||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
@ -35,7 +35,7 @@ export type EditDocumentFormProps = {
|
|||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||||
|
|
||||||
export const EditDocumentForm = ({
|
export const EditDocumentForm = ({
|
||||||
className,
|
className,
|
||||||
@ -48,30 +48,65 @@ export const EditDocumentForm = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
const [step, setStep] = useState<EditDocumentStep>(
|
||||||
|
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
||||||
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||||
|
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
||||||
|
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
|
title: {
|
||||||
|
title: 'Add Title',
|
||||||
|
description: 'Add the title to the document.',
|
||||||
|
stepIndex: 1,
|
||||||
|
},
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Signers',
|
title: 'Add Signers',
|
||||||
description: 'Add the people who will sign the document.',
|
description: 'Add the people who will sign the document.',
|
||||||
stepIndex: 1,
|
stepIndex: 2,
|
||||||
|
onBackStep: () => document.status === DocumentStatus.DRAFT && setStep('title'),
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add Fields',
|
title: 'Add Fields',
|
||||||
description: 'Add all relevant fields for each recipient.',
|
description: 'Add all relevant fields for each recipient.',
|
||||||
stepIndex: 2,
|
stepIndex: 3,
|
||||||
onBackStep: () => setStep('signers'),
|
onBackStep: () => setStep('signers'),
|
||||||
},
|
},
|
||||||
subject: {
|
subject: {
|
||||||
title: 'Add Subject',
|
title: 'Add Subject',
|
||||||
description: 'Add the subject and message you wish to send to signers.',
|
description: 'Add the subject and message you wish to send to signers.',
|
||||||
stepIndex: 3,
|
stepIndex: 4,
|
||||||
onBackStep: () => setStep('fields'),
|
onBackStep: () => setStep('fields'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
|
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
||||||
|
try {
|
||||||
|
// Custom invocation server action
|
||||||
|
await addTitle({
|
||||||
|
documentId: document.id,
|
||||||
|
title: data.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
setStep('signers');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while updating title.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// Custom invocation server action
|
// Custom invocation server action
|
||||||
@ -120,7 +155,7 @@ export const EditDocumentForm = ({
|
|||||||
const { subject, message } = data.email;
|
const { subject, message } = data.email;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument({
|
await sendDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
email: {
|
email: {
|
||||||
subject,
|
subject,
|
||||||
@ -158,16 +193,32 @@ export const EditDocumentForm = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
|
<DocumentFlowFormContainer
|
||||||
|
className="lg:h-[calc(100vh-6rem)]"
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<DocumentFlowFormContainerHeader
|
<DocumentFlowFormContainerHeader
|
||||||
title={currentDocumentFlow.title}
|
title={currentDocumentFlow.title}
|
||||||
description={currentDocumentFlow.description}
|
description={currentDocumentFlow.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{step === 'title' && (
|
||||||
|
<AddTitleFormPartial
|
||||||
|
key={recipients.length}
|
||||||
|
documentFlow={documentFlow.title}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
document={document}
|
||||||
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
onSubmit={onAddTitleFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{step === 'signers' && (
|
{step === 'signers' && (
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
numberOfSteps={Object.keys(documentFlow).length}
|
numberOfSteps={Object.keys(documentFlow).length}
|
||||||
|
|||||||
@ -43,11 +43,11 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
const { documentData } = document;
|
const { documentData } = document;
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
const [recipients, fields] = await Promise.all([
|
||||||
await getRecipientsForDocument({
|
getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}),
|
}),
|
||||||
await getFieldsForDocument({
|
getFieldsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||||
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
|
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
@ -60,7 +60,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
// const isPending = row.status === DocumentStatus.PENDING;
|
// const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
|
const isDocumentDeletable = isOwner;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
let document: DocumentWithData | null = null;
|
let document: DocumentWithData | null = null;
|
||||||
@ -161,8 +161,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
{isDocumentDeletable && (
|
||||||
<DeleteDraftDocumentDialog
|
<DeleteDocumentDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
|
status={row.status}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
@ -74,12 +75,14 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) =>
|
||||||
<div className="flex items-center gap-x-4">
|
(!row.original.deletedAt ||
|
||||||
<DataTableActionButton row={row.original} />
|
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||||
<DataTableActionDropdown row={row.original} />
|
<div className="flex items-center gap-x-4">
|
||||||
</div>
|
<DataTableActionButton row={row.original} />
|
||||||
),
|
<DataTableActionDropdown row={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
data={results.data}
|
data={results.data}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -10,41 +13,46 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type DeleteDraftDocumentDialogProps = {
|
type DeleteDraftDocumentDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
status: DocumentStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteDraftDocumentDialog = ({
|
export const DeleteDocumentDialog = ({
|
||||||
id,
|
id,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
status,
|
||||||
}: DeleteDraftDocumentDialogProps) => {
|
}: DeleteDraftDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: deleteDocument, isLoading } =
|
const [inputValue, setInputValue] = useState('');
|
||||||
trpcReact.document.deleteDraftDocument.useMutation({
|
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
|
||||||
onSuccess: () => {
|
|
||||||
router.refresh();
|
|
||||||
|
|
||||||
toast({
|
const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({
|
||||||
title: 'Document deleted',
|
onSuccess: () => {
|
||||||
description: 'Your document has been successfully deleted.',
|
router.refresh();
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
toast({
|
||||||
},
|
title: 'Document deleted',
|
||||||
});
|
description: 'Your document has been successfully deleted.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
const onDraftDelete = async () => {
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteDocument({ id });
|
await deleteDocument({ id, status });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
@ -55,6 +63,11 @@ export const DeleteDraftDocumentDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(event.target.value);
|
||||||
|
setIsDeleteEnabled(event.target.value === 'delete');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@ -67,6 +80,17 @@ export const DeleteDraftDocumentDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{status !== DocumentStatus.DRAFT && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={onInputChange}
|
||||||
|
placeholder="Type 'delete' to confirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
@ -78,7 +102,14 @@ export const DeleteDraftDocumentDialog = ({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" loading={isLoading} onClick={onDraftDelete} className="flex-1">
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={!isDeleteEnabled}
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<h3 className="text-2xl font-semibold">Billing</h3>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
{isMissingOrInactiveOrFreePlan && (
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
|
|||||||
@ -1,19 +1,5 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
export default function PasswordSettingsPage() {
|
||||||
|
redirect('/settings/security');
|
||||||
export default async function PasswordSettingsPage() {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Password</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Profile</h3>
|
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||||
|
|
||||||
|
|||||||
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
|
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||||
|
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||||
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
|
|
||||||
|
export default async function SecuritySettingsPage() {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">Security</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Here you can manage your password and security settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<PasswordForm user={user} className="max-w-xl" />
|
||||||
|
|
||||||
|
<hr className="mb-4 mt-8" />
|
||||||
|
|
||||||
|
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Add and manage your two factor security settings to add an extra layer of security to your
|
||||||
|
account!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 max-w-xl">
|
||||||
|
<h5 className="font-medium">Two-factor methods</h5>
|
||||||
|
|
||||||
|
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.twoFactorEnabled && (
|
||||||
|
<div className="mt-4 max-w-xl">
|
||||||
|
<h5 className="font-medium">Recovery methods</h5>
|
||||||
|
|
||||||
|
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import Link from 'next/link';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { CheckCircle2, Clock8 } from 'lucide-react';
|
import { CheckCircle2, Clock8 } from 'lucide-react';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
@ -53,6 +54,9 @@ export default async function CompletedSigningPage({
|
|||||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||||
recipient.email;
|
recipient.email;
|
||||||
|
|
||||||
|
const sessionData = await getServerSession();
|
||||||
|
const isLoggedIn = !!sessionData?.user;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
||||||
{/* Card with recipient */}
|
{/* Card with recipient */}
|
||||||
@ -63,18 +67,24 @@ export default async function CompletedSigningPage({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative mt-6 flex w-full flex-col items-center">
|
<div className="relative mt-6 flex w-full flex-col items-center">
|
||||||
{match(document.status)
|
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||||
<div className="text-documenso-700 flex items-center text-center">
|
<div className="text-documenso-700 flex items-center text-center">
|
||||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||||
<span className="text-sm">Everyone has signed</span>
|
<span className="text-sm">Everyone has signed</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.with({ deletedAt: null }, () => (
|
||||||
<div className="flex items-center text-center text-blue-600">
|
<div className="flex items-center text-center text-blue-600">
|
||||||
<Clock8 className="mr-2 h-5 w-5" />
|
<Clock8 className="mr-2 h-5 w-5" />
|
||||||
<span className="text-sm">Waiting for others to sign</span>
|
<span className="text-sm">Waiting for others to sign</span>
|
||||||
</div>
|
</div>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<div className="flex items-center text-center text-red-600">
|
||||||
|
<Clock8 className="mr-2 h-5 w-5" />
|
||||||
|
<span className="text-sm">Document no longer available to sign</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
@ -82,16 +92,22 @@ export default async function CompletedSigningPage({
|
|||||||
<span className="mt-1.5 block">"{document.title}"</span>
|
<span className="mt-1.5 block">"{document.title}"</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{match(document.status)
|
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||||
.with(DocumentStatus.COMPLETED, () => (
|
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
Everyone has signed! You will receive an Email copy of the signed document.
|
Everyone has signed! You will receive an Email copy of the signed document.
|
||||||
</p>
|
</p>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.with({ deletedAt: null }, () => (
|
||||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
You will receive an Email copy of the signed document once everyone has signed.
|
You will receive an Email copy of the signed document once everyone has signed.
|
||||||
</p>
|
</p>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
This document has been cancelled by the owner and is no longer available for others to
|
||||||
|
sign.
|
||||||
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
@ -105,15 +121,21 @@ export default async function CompletedSigningPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
{isLoggedIn ? (
|
||||||
Want to send slick signing links like this one?{' '}
|
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
||||||
<Link
|
Go Back Home
|
||||||
href="https://documenso.com"
|
|
||||||
className="text-documenso-700 hover:text-documenso-600"
|
|
||||||
>
|
|
||||||
Check out Documenso.
|
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
) : (
|
||||||
|
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||||
|
Want to send slick signing links like this one?{' '}
|
||||||
|
<Link
|
||||||
|
href="https://documenso.com"
|
||||||
|
className="text-documenso-700 hover:text-documenso-600"
|
||||||
|
>
|
||||||
|
Check out Documenso.
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { Document, Field, Recipient } from '@documenso/prisma/client';
|
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -34,6 +34,9 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: completeDocumentWithToken } =
|
||||||
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Clock8 } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
|
import type { Document, Signature } from '@documenso/prisma/client';
|
||||||
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
|
type NoLongerAvailableProps = {
|
||||||
|
document: Document;
|
||||||
|
recipientName: string;
|
||||||
|
recipientSignature: Signature;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoLongerAvailable = ({
|
||||||
|
document,
|
||||||
|
recipientName,
|
||||||
|
recipientSignature,
|
||||||
|
}: NoLongerAvailableProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
||||||
|
<SigningCard3D
|
||||||
|
name={recipientName}
|
||||||
|
signature={recipientSignature}
|
||||||
|
signingCelebrationImage={signingCelebration}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mt-2 flex w-full flex-col items-center">
|
||||||
|
<div className="mt-8 flex items-center text-center text-red-600">
|
||||||
|
<Clock8 className="mr-2 h-5 w-5" />
|
||||||
|
<span className="text-sm">Document Cancelled</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
|
<span className="mt-1.5 block">"{document.title}"</span>
|
||||||
|
is no longer available to sign
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||||
|
This document has been cancelled by the owner.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{session?.user ? (
|
||||||
|
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
||||||
|
Go Back Home
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||||
|
Want to send slick signing links like this one?{' '}
|
||||||
|
<Link
|
||||||
|
href="https://documenso.com"
|
||||||
|
className="text-documenso-700 hover:text-documenso-600"
|
||||||
|
>
|
||||||
|
Check out Documenso.
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -8,6 +8,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
|
|||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
@ -17,6 +18,7 @@ import { DateField } from './date-field';
|
|||||||
import { EmailField } from './email-field';
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
import { NameField } from './name-field';
|
import { NameField } from './name-field';
|
||||||
|
import { NoLongerAvailable } from './no-longer-available';
|
||||||
import { SigningProvider } from './provider';
|
import { SigningProvider } from './provider';
|
||||||
import { SignatureField } from './signature-field';
|
import { SignatureField } from './signature-field';
|
||||||
|
|
||||||
@ -55,6 +57,18 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
redirect(`/sign/${token}/complete`);
|
redirect(`/sign/${token}/complete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
|
||||||
|
|
||||||
|
if (document.deletedAt) {
|
||||||
|
return (
|
||||||
|
<NoLongerAvailable
|
||||||
|
document={document}
|
||||||
|
recipientName={recipient.name}
|
||||||
|
recipientSignature={recipientSignature}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningProvider
|
<SigningProvider
|
||||||
email={recipient.email}
|
email={recipient.email}
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
@ -76,10 +76,16 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: source === 'local' && localSignature ? localSignature : providedSignature ?? '',
|
value,
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -69,15 +69,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">Waiting</h1>
|
<h1 className="text-base font-medium">Waiting</h1>
|
||||||
{waitingRecipients.map((recipient: Recipient) => (
|
{waitingRecipients.map((recipient: Recipient) => (
|
||||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||||
<StackAvatar
|
|
||||||
first={true}
|
|
||||||
key={recipient.id}
|
|
||||||
type={getRecipientType(recipient)}
|
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
DOCUMENTS_PAGE_SHORTCUT,
|
DOCUMENTS_PAGE_SHORTCUT,
|
||||||
SETTINGS_PAGE_SHORTCUT,
|
SETTINGS_PAGE_SHORTCUT,
|
||||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@ -29,13 +30,20 @@ const DOCUMENTS_PAGES = [
|
|||||||
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
||||||
},
|
},
|
||||||
{ label: 'Draft documents', path: '/documents?status=DRAFT' },
|
{ label: 'Draft documents', path: '/documents?status=DRAFT' },
|
||||||
{ label: 'Completed documents', path: '/documents?status=COMPLETED' },
|
{
|
||||||
|
label: 'Completed documents',
|
||||||
|
path: '/documents?status=COMPLETED',
|
||||||
|
},
|
||||||
{ label: 'Pending documents', path: '/documents?status=PENDING' },
|
{ label: 'Pending documents', path: '/documents?status=PENDING' },
|
||||||
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SETTINGS_PAGES = [
|
const SETTINGS_PAGES = [
|
||||||
{ label: 'Settings', path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', '') },
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
path: '/settings',
|
||||||
|
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
|
||||||
|
},
|
||||||
{ label: 'Profile', path: '/settings/profile' },
|
{ label: 'Profile', path: '/settings/profile' },
|
||||||
{ label: 'Password', path: '/settings/password' },
|
{ label: 'Password', path: '/settings/password' },
|
||||||
];
|
];
|
||||||
@ -53,6 +61,29 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [pages, setPages] = useState<string[]>([]);
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
|
||||||
|
trpcReact.document.searchDocuments.useQuery(
|
||||||
|
{
|
||||||
|
query: search,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
if (!searchDocumentsData) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchDocumentsData.map((document) => ({
|
||||||
|
label: document.title,
|
||||||
|
path: `/documents/${document.id}`,
|
||||||
|
value:
|
||||||
|
document.title + ' ' + document.Recipient.map((recipient) => recipient.email).join(' '),
|
||||||
|
}));
|
||||||
|
}, [searchDocumentsData]);
|
||||||
|
|
||||||
const currentPage = pages[pages.length - 1];
|
const currentPage = pages[pages.length - 1];
|
||||||
|
|
||||||
const toggleOpen = () => {
|
const toggleOpen = () => {
|
||||||
@ -113,7 +144,13 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog commandProps={{ onKeyDown: handleKeyDown }} open={open} onOpenChange={setOpen}>
|
<CommandDialog
|
||||||
|
commandProps={{
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
value={search}
|
value={search}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
@ -121,7 +158,17 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
{isSearchingDocuments ? (
|
||||||
|
<CommandEmpty>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="animate-spin">
|
||||||
|
<Loader />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandEmpty>
|
||||||
|
) : (
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
)}
|
||||||
{!currentPage && (
|
{!currentPage && (
|
||||||
<>
|
<>
|
||||||
<CommandGroup heading="Documents">
|
<CommandGroup heading="Documents">
|
||||||
@ -133,6 +180,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
<CommandGroup heading="Preferences">
|
<CommandGroup heading="Preferences">
|
||||||
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<CommandGroup heading="Your documents">
|
||||||
|
<Commands push={push} pages={searchResults} />
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
|
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
|
||||||
@ -146,10 +198,14 @@ const Commands = ({
|
|||||||
pages,
|
pages,
|
||||||
}: {
|
}: {
|
||||||
push: (_path: string) => void;
|
push: (_path: string) => void;
|
||||||
pages: { label: string; path: string; shortcut?: string }[];
|
pages: { label: string; path: string; shortcut?: string; value?: string }[];
|
||||||
}) => {
|
}) => {
|
||||||
return pages.map((page) => (
|
return pages.map((page, idx) => (
|
||||||
<CommandItem key={page.path} onSelect={() => push(page.path)}>
|
<CommandItem
|
||||||
|
key={page.path + idx}
|
||||||
|
value={page.value ?? page.label}
|
||||||
|
onSelect={() => push(page.path)}
|
||||||
|
>
|
||||||
{page.label}
|
{page.label}
|
||||||
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
|
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Key,
|
Lock,
|
||||||
LogOut,
|
LogOut,
|
||||||
User as LucideUser,
|
User as LucideUser,
|
||||||
Monitor,
|
Monitor,
|
||||||
@ -20,7 +20,7 @@ import { LuGithub } from 'react-icons/lu';
|
|||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -56,7 +56,11 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
title="Profile Dropdown"
|
||||||
|
className="relative h-10 w-10 rounded-full"
|
||||||
|
>
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@ -87,9 +91,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/password" className="cursor-pointer">
|
<Link href="/settings/security" className="cursor-pointer">
|
||||||
<Key className="mr-2 h-4 w-4" />
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
Password
|
Security
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Key, User } from 'lucide-react';
|
import { Braces, CreditCard, Lock, User } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/password">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Key className="mr-2 h-5 w-5" />
|
<Lock className="mr-2 h-5 w-5" />
|
||||||
Password
|
Security
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Key, User } from 'lucide-react';
|
import { Braces, CreditCard, Lock, User } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/password">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Key className="mr-2 h-5 w-5" />
|
<Lock className="mr-2 h-5 w-5" />
|
||||||
Password
|
Security
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
|
||||||
|
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
|
||||||
|
|
||||||
|
type AuthenticatorAppProps = {
|
||||||
|
isTwoFactorEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
|
||||||
|
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
|
||||||
|
|
||||||
|
const isEnableDialogOpen = modalState === 'enable';
|
||||||
|
const isDisableDialogOpen = modalState === 'disable';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p>Authenticator app</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||||
|
Create one-time passwords that serve as a secondary authentication method for confirming
|
||||||
|
your identity when requested during the sign-in process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isTwoFactorEnabled ? (
|
||||||
|
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setModalState('enable')} size="sm">
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EnableAuthenticatorAppDialog
|
||||||
|
key={isEnableDialogOpen ? 'open' : 'closed'}
|
||||||
|
open={isEnableDialogOpen}
|
||||||
|
onOpenChange={(open) => !open && setModalState(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DisableAuthenticatorAppDialog
|
||||||
|
key={isDisableDialogOpen ? 'open' : 'closed'}
|
||||||
|
open={isDisableDialogOpen}
|
||||||
|
onOpenChange={(open) => !open && setModalState(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
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,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} 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 const ZDisableTwoFactorAuthenticationForm = z.object({
|
||||||
|
password: z.string().min(6).max(72),
|
||||||
|
backupCode: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
||||||
|
typeof ZDisableTwoFactorAuthenticationForm
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DisableAuthenticatorAppDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisableAuthenticatorAppDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DisableAuthenticatorAppDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: disableTwoFactorAuthentication } =
|
||||||
|
trpc.twoFactorAuthentication.disable.useMutation();
|
||||||
|
|
||||||
|
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
backupCode: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
||||||
|
disableTwoFactorAuthenticationForm.formState;
|
||||||
|
|
||||||
|
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
||||||
|
password,
|
||||||
|
backupCode,
|
||||||
|
}: TDisableTwoFactorAuthenticationForm) => {
|
||||||
|
try {
|
||||||
|
await disableTwoFactorAuthentication({ password, backupCode });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Two-factor authentication disabled',
|
||||||
|
description:
|
||||||
|
'Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.',
|
||||||
|
});
|
||||||
|
|
||||||
|
flushSync(() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: 'Unable to disable two-factor authentication',
|
||||||
|
description:
|
||||||
|
'We were unable to disable two-factor authentication for your account. Please ensure that you have entered your password and backup code correctly and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Disable Authenticator App</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
To disable the Authenticator App for your account, please enter your password and a
|
||||||
|
backup code. If you do not have a backup code available, please contact support.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...disableTwoFactorAuthenticationForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
||||||
|
onDisableTwoFactorAuthenticationFormSubmit,
|
||||||
|
)}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={disableTwoFactorAuthenticationForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="backupCode"
|
||||||
|
control={disableTwoFactorAuthenticationForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="text" value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDisableTwoFactorAuthenticationSubmitting}
|
||||||
|
>
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,283 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { renderSVG } from 'uqr';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
|
export const ZSetupTwoFactorAuthenticationForm = z.object({
|
||||||
|
password: z.string().min(6).max(72),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
|
||||||
|
|
||||||
|
export const ZEnableTwoFactorAuthenticationForm = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
||||||
|
|
||||||
|
export type EnableAuthenticatorAppDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnableAuthenticatorAppDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: EnableAuthenticatorAppDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||||
|
trpc.twoFactorAuthentication.setup.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
|
||||||
|
trpc.twoFactorAuthentication.enable.useMutation();
|
||||||
|
|
||||||
|
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
||||||
|
setupTwoFactorAuthenticationForm.formState;
|
||||||
|
|
||||||
|
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
||||||
|
defaultValues: {
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
||||||
|
enableTwoFactorAuthenticationForm.formState;
|
||||||
|
|
||||||
|
const step = useMemo(() => {
|
||||||
|
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
|
||||||
|
return 'setup';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
|
||||||
|
return 'enable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'view';
|
||||||
|
}, [
|
||||||
|
setupTwoFactorAuthenticationData,
|
||||||
|
isSetupTwoFactorAuthenticationSubmitting,
|
||||||
|
enableTwoFactorAuthenticationData,
|
||||||
|
isEnableTwoFactorAuthenticationSubmitting,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onSetupTwoFactorAuthenticationFormSubmit = async ({
|
||||||
|
password,
|
||||||
|
}: TSetupTwoFactorAuthenticationForm) => {
|
||||||
|
try {
|
||||||
|
await setupTwoFactorAuthentication({ password });
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: 'Unable to setup two-factor authentication',
|
||||||
|
description:
|
||||||
|
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
||||||
|
token,
|
||||||
|
}: TEnableTwoFactorAuthenticationForm) => {
|
||||||
|
try {
|
||||||
|
await enableTwoFactorAuthentication({ code: token });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Two-factor authentication enabled',
|
||||||
|
description:
|
||||||
|
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: 'Unable to setup two-factor authentication',
|
||||||
|
description:
|
||||||
|
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCompleteClick = () => {
|
||||||
|
flushSync(() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||||
|
|
||||||
|
{step === 'setup' && (
|
||||||
|
<DialogDescription>
|
||||||
|
To enable two-factor authentication, please enter your password below.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'view' && (
|
||||||
|
<DialogDescription>
|
||||||
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{match(step)
|
||||||
|
.with('setup', () => {
|
||||||
|
return (
|
||||||
|
<Form {...setupTwoFactorAuthenticationForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
|
||||||
|
onSetupTwoFactorAuthenticationFormSubmit,
|
||||||
|
)}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={setupTwoFactorAuthenticationForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with('enable', () => (
|
||||||
|
<Form {...enableTwoFactorAuthenticationForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
|
||||||
|
onEnableTwoFactorAuthenticationFormSubmit,
|
||||||
|
)}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
To enable two-factor authentication, scan the following QR code using your
|
||||||
|
authenticator app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-36 justify-center"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
If your authenticator app does not support QR codes, you can use the following
|
||||||
|
code instead:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||||
|
{setupTwoFactorAuthenticationData?.secret}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Once you have scanned the QR code or entered the code manually, enter the code
|
||||||
|
provided by your authenticator app below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="token"
|
||||||
|
control={enableTwoFactorAuthenticationForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="text" value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
))
|
||||||
|
.with('view', () => (
|
||||||
|
<div>
|
||||||
|
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
||||||
|
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
||||||
|
<Button type="button" onClick={() => onCompleteClick()}>
|
||||||
|
Complete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Copy } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type RecoveryCodeListProps = {
|
||||||
|
recoveryCodes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecoveryCodeList = ({ recoveryCodes }: RecoveryCodeListProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
||||||
|
const onCopyRecoveryCodeClick = async (code: string) => {
|
||||||
|
try {
|
||||||
|
const result = await copyToClipboard(code);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Unable to copy recovery code');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Recovery code copied',
|
||||||
|
description: 'Your recovery code has been copied to your clipboard.',
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: 'Unable to copy recovery code',
|
||||||
|
description:
|
||||||
|
'We were unable to copy your recovery code to your clipboard. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{recoveryCodes.map((code) => (
|
||||||
|
<div
|
||||||
|
key={code}
|
||||||
|
className="bg-muted text-muted-foreground relative rounded-lg p-4 font-mono md:text-center"
|
||||||
|
>
|
||||||
|
<span>{code}</span>
|
||||||
|
|
||||||
|
<div className="absolute inset-y-0 right-4 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
className="opacity-60 hover:opacity-80"
|
||||||
|
onClick={() => void onCopyRecoveryCodeClick(code)}
|
||||||
|
>
|
||||||
|
<Copy className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
||||||
|
|
||||||
|
type RecoveryCodesProps = {
|
||||||
|
// backupCodes: string[] | null;
|
||||||
|
isTwoFactorEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p>Recovery Codes</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||||
|
Recovery codes are used to access your account in the event that you lose access to your
|
||||||
|
authenticator app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
|
||||||
|
View Codes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ViewRecoveryCodesDialog
|
||||||
|
key={isOpen ? 'open' : 'closed'}
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
|
export const ZViewRecoveryCodesForm = z.object({
|
||||||
|
password: z.string().min(6).max(72),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
||||||
|
|
||||||
|
export type ViewRecoveryCodesDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
|
||||||
|
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||||
|
|
||||||
|
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
||||||
|
|
||||||
|
const step = useMemo(() => {
|
||||||
|
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
||||||
|
return 'authenticate';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'view';
|
||||||
|
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
||||||
|
|
||||||
|
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
||||||
|
try {
|
||||||
|
await viewRecoveryCodes({ password });
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: 'Unable to view recovery codes',
|
||||||
|
description:
|
||||||
|
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||||
|
|
||||||
|
{step === 'authenticate' && (
|
||||||
|
<DialogDescription>
|
||||||
|
To view your recovery codes, please enter your password below.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'view' && (
|
||||||
|
<DialogDescription>
|
||||||
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{match(step)
|
||||||
|
.with('authenticate', () => {
|
||||||
|
return (
|
||||||
|
<Form {...viewRecoveryCodesForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={viewRecoveryCodesForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with('view', () => (
|
||||||
|
<div>
|
||||||
|
{viewRecoveryCodesData?.recoveryCodes && (
|
||||||
|
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-row-reverse items-center justify-between">
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,30 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
|
||||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
|
||||||
|
|
||||||
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
|
||||||
documentId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const addFields = async ({ documentId, fields }: AddFieldsActionInput) => {
|
|
||||||
'use server';
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
await setFieldsForDocument({
|
|
||||||
userId: user.id,
|
|
||||||
documentId,
|
|
||||||
fields: fields.map((field) => ({
|
|
||||||
id: field.nativeId,
|
|
||||||
signerEmail: field.signerEmail,
|
|
||||||
type: field.type,
|
|
||||||
pageNumber: field.pageNumber,
|
|
||||||
pageX: field.pageX,
|
|
||||||
pageY: field.pageY,
|
|
||||||
pageWidth: field.pageWidth,
|
|
||||||
pageHeight: field.pageHeight,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
|
||||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
|
||||||
|
|
||||||
export type AddSignersActionInput = TAddSignersFormSchema & {
|
|
||||||
documentId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => {
|
|
||||||
'use server';
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
await setRecipientsForDocument({
|
|
||||||
userId: user.id,
|
|
||||||
documentId,
|
|
||||||
recipients: signers.map((signer) => ({
|
|
||||||
id: signer.nativeId,
|
|
||||||
email: signer.email,
|
|
||||||
name: signer.name,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
|
||||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
|
||||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
|
||||||
|
|
||||||
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
|
||||||
documentId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => {
|
|
||||||
'use server';
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
if (email.message || email.subject) {
|
|
||||||
await upsertDocumentMeta({
|
|
||||||
documentId,
|
|
||||||
subject: email.subject,
|
|
||||||
message: email.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return await sendDocument({
|
|
||||||
userId: user.id,
|
|
||||||
documentId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -3,7 +3,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
@ -12,23 +11,30 @@ import { z } from 'zod';
|
|||||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input, PasswordInput } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ERROR_MESSAGES = {
|
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||||
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||||
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||||
[ErrorCode.USER_MISSING_PASSWORD]:
|
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||||
'This account appears to be using a social login method, please sign in using that method',
|
'This account appears to be using a social login method, please sign in using that method',
|
||||||
|
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
|
||||||
|
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
||||||
|
|
||||||
const LOGIN_REDIRECT_PATH = '/documents';
|
const LOGIN_REDIRECT_PATH = '/documents';
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(6, { message: 'Invalid password' }).max(72),
|
password: z.string().min(6).max(72),
|
||||||
|
totpCode: z.string().trim().optional(),
|
||||||
|
backupCode: z.string().trim().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||||
@ -39,33 +45,84 @@ export type SignInFormProps = {
|
|||||||
|
|
||||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
|
'totp' | 'backup'
|
||||||
|
>('totp');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<TSignInFormSchema>({
|
} = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
totpCode: '',
|
||||||
|
backupCode: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZSignInFormSchema),
|
resolver: zodResolver(ZSignInFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
const onCloseTwoFactorAuthenticationDialog = () => {
|
||||||
|
setValue('totpCode', '');
|
||||||
|
setValue('backupCode', '');
|
||||||
|
|
||||||
|
setIsTwoFactorAuthenticationDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleTwoFactorAuthenticationMethodClick = () => {
|
||||||
|
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
|
||||||
|
|
||||||
|
if (method === 'totp') {
|
||||||
|
setValue('backupCode', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'backup') {
|
||||||
|
setValue('totpCode', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTwoFactorAuthenticationMethod(method);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const result = await signIn('credentials', {
|
const credentials: Record<string, string> = {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (totpCode) {
|
||||||
|
credentials.totpCode = totpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupCode) {
|
||||||
|
credentials.backupCode = backupCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
...credentials,
|
||||||
|
|
||||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error && isErrorCode(result.error)) {
|
if (result?.error && isErrorCode(result.error)) {
|
||||||
|
if (result.error === TwoFactorEnabledErrorCode) {
|
||||||
|
setIsTwoFactorAuthenticationDialogOpen(true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = ERROR_MESSAGES[result.error];
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: ERROR_MESSAGES[result.error],
|
title: 'Unable to sign in',
|
||||||
|
description: errorMessage ?? 'An unknown error occurred',
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -118,31 +175,14 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<span>Password</span>
|
<span>Password</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div className="relative">
|
<PasswordInput
|
||||||
<Input
|
id="password"
|
||||||
id="password"
|
minLength={6}
|
||||||
type={showPassword ? 'text' : 'password'}
|
maxLength={72}
|
||||||
minLength={6}
|
className="bg-background mt-2"
|
||||||
maxLength={72}
|
autoComplete="current-password"
|
||||||
autoComplete="current-password"
|
{...register('password')}
|
||||||
className="bg-background mt-2 pr-10"
|
/>
|
||||||
{...register('password')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
type="button"
|
|
||||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
|
||||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
|
||||||
onClick={() => setShowPassword((show) => !show)}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="text-muted-foreground h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||||
</div>
|
</div>
|
||||||
@ -173,6 +213,67 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isTwoFactorAuthenticationDialogOpen}
|
||||||
|
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Two-Factor Authentication</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
{twoFactorAuthenticationMethod === 'totp' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="totpCode" className="text-muted-forground">
|
||||||
|
Authentication Token
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="totpCode"
|
||||||
|
type="text"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
{...register('totpCode')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.totpCode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{twoFactorAuthenticationMethod === 'backup' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="backupCode" className="text-muted-forground">
|
||||||
|
Backup Code
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="backupCode"
|
||||||
|
type="text"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
{...register('backupCode')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.backupCode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
||||||
|
>
|
||||||
|
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isSubmitting}>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,10 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next';
|
|||||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||||
import { appRouter } from '@documenso/trpc/server/router';
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
maxDuration: 60,
|
||||||
|
};
|
||||||
|
|
||||||
export default trpcNext.createNextApiHandler({
|
export default trpcNext.createNextApiHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||||
|
|||||||
5833
package-lock.json
generated
5833
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -4,7 +4,7 @@
|
|||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:web": "turbo run build --filter=@documenso/web",
|
"build:web": "turbo run build --filter=@documenso/web",
|
||||||
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
||||||
"start": "cd apps && cd web && next start",
|
"start": "turbo run start --filter=@documenso/web --filter=@documenso/marketing",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"lint:fix": "turbo run lint:fix",
|
"lint:fix": "turbo run lint:fix",
|
||||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||||
@ -19,6 +19,7 @@
|
|||||||
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
|
"prisma:generate": "npm run with:env -- npm run prisma:generate -w @documenso/prisma",
|
||||||
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
|
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma",
|
||||||
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
|
||||||
|
"prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma",
|
||||||
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
||||||
"with:env": "dotenv -e .env -e .env.local --",
|
"with:env": "dotenv -e .env -e .env.local --",
|
||||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
|
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
|
||||||
@ -46,8 +47,13 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {},
|
||||||
"recharts": "^2.7.2",
|
"overrides": {
|
||||||
"react-hotkeys-hook": "^4.4.1"
|
"next-auth": {
|
||||||
|
"next": "14.0.3"
|
||||||
|
},
|
||||||
|
"next-contentlayer": {
|
||||||
|
"next": "14.0.3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
192
packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
Normal file
192
packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
||||||
|
const [sender, ...recipients] = TEST_USERS;
|
||||||
|
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(sender.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTitle('Profile Dropdown').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signin');
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(recipient.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTitle('Profile Dropdown').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[PR-711]: deleting a completed document should not remove it from recipients', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const [sender, ...recipients] = TEST_USERS;
|
||||||
|
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
// sign in
|
||||||
|
await page.getByLabel('Email').fill(sender.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
// open actions menu
|
||||||
|
await page
|
||||||
|
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||||
|
.getByRole('cell', { name: 'Download' })
|
||||||
|
.getByRole('button')
|
||||||
|
.nth(1)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// delete document
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||||
|
|
||||||
|
// signout
|
||||||
|
await page.getByTitle('Profile Dropdown').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signin');
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
// sign in
|
||||||
|
await page.getByLabel('Email').fill(recipient.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto(`/sign/completed-token-${recipients.indexOf(recipient)}`);
|
||||||
|
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/documents');
|
||||||
|
|
||||||
|
await page.getByTitle('Profile Dropdown').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[PR-711]: deleting a pending document should remove it from recipients', async ({ page }) => {
|
||||||
|
const [sender, ...recipients] = TEST_USERS;
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
|
||||||
|
|
||||||
|
await expect(page.getByText('Waiting for others to sign').nth(0)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
// sign in
|
||||||
|
await page.getByLabel('Email').fill(sender.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
// open actions menu
|
||||||
|
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
|
||||||
|
|
||||||
|
// delete document
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||||
|
|
||||||
|
// signout
|
||||||
|
await page.getByTitle('Profile Dropdown').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signin');
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
// sign in
|
||||||
|
await page.getByLabel('Email').fill(recipient.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.goto(`/sign/pending-token-${recipients.indexOf(recipient)}`);
|
||||||
|
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/documents');
|
||||||
|
|
||||||
|
await page.getByTitle('Profile Dropdown').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[PR-711]: deleting a draft document should remove it without additional prompting', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const [sender] = TEST_USERS;
|
||||||
|
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
// sign in
|
||||||
|
await page.getByLabel('Email').fill(sender.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
// open actions menu
|
||||||
|
await page
|
||||||
|
.locator('tr', { hasText: 'Document 1 - Draft' })
|
||||||
|
.getByRole('cell', { name: 'Edit' })
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// delete document
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||||
|
});
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { TEST_USERS } from '@documenso/prisma/seed/pr-713-add-document-search-to-command-menu';
|
||||||
|
|
||||||
|
test('[PR-713]: should see sent documents', async ({ page }) => {
|
||||||
|
const [user] = TEST_USERS;
|
||||||
|
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(user.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
await page.keyboard.press('Meta+K');
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Type a command or search...').fill('sent');
|
||||||
|
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// signout
|
||||||
|
await page.getByTitle('Profile Dropdown').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[PR-713]: should see received documents', async ({ page }) => {
|
||||||
|
const [user] = TEST_USERS;
|
||||||
|
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(user.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
await page.keyboard.press('Meta+K');
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Type a command or search...').fill('received');
|
||||||
|
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// signout
|
||||||
|
await page.getByTitle('Profile Dropdown').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
||||||
|
const [user, recipient] = TEST_USERS;
|
||||||
|
|
||||||
|
await page.goto('/signin');
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(user.email);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
|
await page.keyboard.press('Meta+K');
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
|
||||||
|
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// signout
|
||||||
|
await page.getByTitle('Profile Dropdown').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
});
|
||||||
@ -10,9 +10,9 @@ test.use({ storageState: { cookies: [], origins: [] } });
|
|||||||
*/
|
*/
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME;
|
const username = 'Test User';
|
||||||
const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL;
|
const email = 'test-user@auth-flow.documenso.com';
|
||||||
const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD;
|
const password = 'Password123';
|
||||||
|
|
||||||
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||||
await page.goto('/signup');
|
await page.goto('/signup');
|
||||||
|
|||||||
@ -6,13 +6,14 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:dev": "playwright test",
|
"test:dev": "playwright test",
|
||||||
"test:e2e": "start-server-and-test \"(cd ../../apps/web && npm run start)\" http://localhost:3000 \"playwright test\""
|
"test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.18.1",
|
"@playwright/test": "^1.18.1",
|
||||||
"@types/node": "^20.8.2",
|
"@types/node": "^20.8.2",
|
||||||
|
"@documenso/prisma": "*",
|
||||||
"@documenso/web": "*"
|
"@documenso/web": "*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -28,8 +28,12 @@ export default defineConfig({
|
|||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
|
video: 'retain-on-failure',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
timeout: 30_000,
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
|||||||
8
packages/app-tests/tsconfig.json
Normal file
8
packages/app-tests/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@documenso/tsconfig/react-library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["@documenso/tsconfig/process-env.d.ts"]
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
|
||||||
|
"exclude": ["dist", "build", "node_modules"]
|
||||||
|
}
|
||||||
@ -17,8 +17,8 @@
|
|||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.0",
|
"next": "14.0.3",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.5",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
|||||||
17
packages/email/components.ts
Normal file
17
packages/email/components.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export * from '@react-email/body';
|
||||||
|
export * from '@react-email/button';
|
||||||
|
export * from '@react-email/column';
|
||||||
|
export * from '@react-email/container';
|
||||||
|
export * from '@react-email/font';
|
||||||
|
export * from '@react-email/head';
|
||||||
|
export * from '@react-email/heading';
|
||||||
|
export * from '@react-email/hr';
|
||||||
|
export * from '@react-email/html';
|
||||||
|
export * from '@react-email/img';
|
||||||
|
export * from '@react-email/link';
|
||||||
|
export * from '@react-email/preview';
|
||||||
|
export * from '@react-email/render';
|
||||||
|
export * from '@react-email/row';
|
||||||
|
export * from '@react-email/section';
|
||||||
|
export * from '@react-email/tailwind';
|
||||||
|
export * from '@react-email/text';
|
||||||
@ -18,7 +18,23 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/nodemailer-resend": "2.0.0",
|
"@documenso/nodemailer-resend": "2.0.0",
|
||||||
"@react-email/components": "^0.0.11",
|
"@react-email/body": "0.0.4",
|
||||||
|
"@react-email/button": "0.0.11",
|
||||||
|
"@react-email/column": "0.0.8",
|
||||||
|
"@react-email/container": "0.0.10",
|
||||||
|
"@react-email/font": "0.0.4",
|
||||||
|
"@react-email/head": "0.0.6",
|
||||||
|
"@react-email/heading": "0.0.9",
|
||||||
|
"@react-email/hr": "0.0.6",
|
||||||
|
"@react-email/html": "0.0.6",
|
||||||
|
"@react-email/img": "0.0.6",
|
||||||
|
"@react-email/link": "0.0.6",
|
||||||
|
"@react-email/preview": "0.0.7",
|
||||||
|
"@react-email/render": "0.0.9",
|
||||||
|
"@react-email/row": "0.0.6",
|
||||||
|
"@react-email/section": "0.0.10",
|
||||||
|
"@react-email/tailwind": "0.0.9",
|
||||||
|
"@react-email/text": "0.0.6",
|
||||||
"nodemailer": "^6.9.3",
|
"nodemailer": "^6.9.3",
|
||||||
"react-email": "^1.9.5",
|
"react-email": "^1.9.5",
|
||||||
"resend": "^2.0.0"
|
"resend": "^2.0.0"
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { render } from '@react-email/components';
|
export { render, renderAsync } from '@react-email/render';
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Section, Text } from '../components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
|
||||||
|
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
export type TemplateConfirmationEmailProps = {
|
export type TemplateConfirmationEmailProps = {
|
||||||
@ -14,15 +11,7 @@ export const TemplateConfirmationEmail = ({
|
|||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
}: TemplateConfirmationEmailProps) => {
|
}: TemplateConfirmationEmailProps) => {
|
||||||
return (
|
return (
|
||||||
<Tailwind
|
<>
|
||||||
config={{
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: config.theme.extend.colors,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
<Section className="flex-row items-center justify-center">
|
<Section className="flex-row items-center justify-center">
|
||||||
@ -47,6 +36,6 @@ export const TemplateConfirmationEmail = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
</Section>
|
</Section>
|
||||||
</Tailwind>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { Section, Text } from '../components';
|
||||||
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
|
export interface TemplateDocumentCancelProps {
|
||||||
|
inviterName: string;
|
||||||
|
inviterEmail: string;
|
||||||
|
documentName: string;
|
||||||
|
assetBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateDocumentCancel = ({
|
||||||
|
inviterName,
|
||||||
|
documentName,
|
||||||
|
assetBaseUrl,
|
||||||
|
}: TemplateDocumentCancelProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
|
{inviterName} has cancelled the document
|
||||||
|
<br />"{documentName}"
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
You don't need to sign it anymore.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateDocumentCancel;
|
||||||
@ -1,7 +1,4 @@
|
|||||||
import { Button, Column, Img, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Column, Img, Section, Text } from '../components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
|
||||||
|
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
export interface TemplateDocumentCompletedProps {
|
export interface TemplateDocumentCompletedProps {
|
||||||
@ -20,15 +17,7 @@ export const TemplateDocumentCompleted = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tailwind
|
<>
|
||||||
config={{
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: config.theme.extend.colors,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
@ -72,7 +61,7 @@ export const TemplateDocumentCompleted = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
</Section>
|
</Section>
|
||||||
</Tailwind>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Column, Img, Row, Section } from '@react-email/components';
|
import { Column, Img, Row, Section } from '../components';
|
||||||
|
|
||||||
export interface TemplateDocumentImageProps {
|
export interface TemplateDocumentImageProps {
|
||||||
assetBaseUrl: string;
|
assetBaseUrl: string;
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Section, Text } from '../components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
|
||||||
|
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
export interface TemplateDocumentInviteProps {
|
export interface TemplateDocumentInviteProps {
|
||||||
@ -19,15 +16,7 @@ export const TemplateDocumentInvite = ({
|
|||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
}: TemplateDocumentInviteProps) => {
|
}: TemplateDocumentInviteProps) => {
|
||||||
return (
|
return (
|
||||||
<Tailwind
|
<>
|
||||||
config={{
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: config.theme.extend.colors,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
@ -49,7 +38,7 @@ export const TemplateDocumentInvite = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
</Section>
|
</Section>
|
||||||
</Tailwind>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { Column, Img, Section, Tailwind, Text } from '@react-email/components';
|
import { Column, Img, Section, Text } from '../components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
|
||||||
|
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
export interface TemplateDocumentPendingProps {
|
export interface TemplateDocumentPendingProps {
|
||||||
@ -18,15 +15,7 @@ export const TemplateDocumentPending = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tailwind
|
<>
|
||||||
config={{
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: config.theme.extend.colors,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
@ -52,7 +41,7 @@ export const TemplateDocumentPending = ({
|
|||||||
We'll notify you as soon as it's ready.
|
We'll notify you as soon as it's ready.
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
</Tailwind>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { Button, Column, Img, Link, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Column, Img, Link, Section, Text } from '../components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
|
||||||
|
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
export interface TemplateDocumentSelfSignedProps {
|
export interface TemplateDocumentSelfSignedProps {
|
||||||
@ -20,15 +17,7 @@ export const TemplateDocumentSelfSigned = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tailwind
|
<>
|
||||||
config={{
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: config.theme.extend.colors,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
<Section className="flex-row items-center justify-center">
|
<Section className="flex-row items-center justify-center">
|
||||||
@ -84,7 +73,7 @@ export const TemplateDocumentSelfSigned = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
</Section>
|
</Section>
|
||||||
</Tailwind>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Link, Section, Text } from '@react-email/components';
|
import { Link, Section, Text } from '../components';
|
||||||
|
|
||||||
export type TemplateFooterProps = {
|
export type TemplateFooterProps = {
|
||||||
isDocument?: boolean;
|
isDocument?: boolean;
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Section, Text } from '../components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
|
||||||
|
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
export type TemplateForgotPasswordProps = {
|
export type TemplateForgotPasswordProps = {
|
||||||
@ -14,15 +11,7 @@ export const TemplateForgotPassword = ({
|
|||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
}: TemplateForgotPasswordProps) => {
|
}: TemplateForgotPasswordProps) => {
|
||||||
return (
|
return (
|
||||||
<Tailwind
|
<>
|
||||||
config={{
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: config.theme.extend.colors,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
<Section className="flex-row items-center justify-center">
|
<Section className="flex-row items-center justify-center">
|
||||||
@ -43,7 +32,7 @@ export const TemplateForgotPassword = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
</Section>
|
</Section>
|
||||||
</Tailwind>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Section, Text } from '../components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
|
||||||
|
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
export interface TemplateResetPasswordProps {
|
export interface TemplateResetPasswordProps {
|
||||||
@ -12,15 +9,7 @@ export interface TemplateResetPasswordProps {
|
|||||||
|
|
||||||
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
|
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
|
||||||
return (
|
return (
|
||||||
<Tailwind
|
<>
|
||||||
config={{
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: config.theme.extend.colors,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
<Section className="flex-row items-center justify-center">
|
<Section className="flex-row items-center justify-center">
|
||||||
@ -41,7 +30,7 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
|
|||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
</Section>
|
</Section>
|
||||||
</Tailwind>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,8 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Container,
|
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Img,
|
|
||||||
Preview,
|
|
||||||
Section,
|
|
||||||
Tailwind,
|
|
||||||
} from '@react-email/components';
|
|
||||||
|
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
import {
|
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||||
TemplateConfirmationEmail,
|
import type { TemplateConfirmationEmailProps } from '../template-components/template-confirmation-email';
|
||||||
TemplateConfirmationEmailProps,
|
import { TemplateConfirmationEmail } from '../template-components/template-confirmation-email';
|
||||||
} from '../template-components/template-confirmation-email';
|
|
||||||
import { TemplateFooter } from '../template-components/template-footer';
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
export const ConfirmEmailTemplate = ({
|
export const ConfirmEmailTemplate = ({
|
||||||
|
|||||||
66
packages/email/templates/document-cancel.tsx
Normal file
66
packages/email/templates/document-cancel.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||||
|
import type { TemplateDocumentCancelProps } from '../template-components/template-document-cancel';
|
||||||
|
import { TemplateDocumentCancel } from '../template-components/template-document-cancel';
|
||||||
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
|
export type DocumentCancelEmailTemplateProps = Partial<TemplateDocumentCancelProps>;
|
||||||
|
|
||||||
|
export const DocumentCancelTemplate = ({
|
||||||
|
inviterName = 'Lucas Smith',
|
||||||
|
inviterEmail = 'lucas@documenso.com',
|
||||||
|
documentName = 'Open Source Pledge.pdf',
|
||||||
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
}: DocumentCancelEmailTemplateProps) => {
|
||||||
|
const previewText = `${inviterName} has cancelled the document ${documentName}, you don't need to sign it anymore.`;
|
||||||
|
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="mx-auto my-auto bg-white font-sans">
|
||||||
|
<Section>
|
||||||
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
|
<Section>
|
||||||
|
<Img
|
||||||
|
src={getAssetUrl('/static/logo.png')}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="mb-4 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateDocumentCancel
|
||||||
|
inviterName={inviterName}
|
||||||
|
inviterEmail={inviterEmail}
|
||||||
|
documentName={documentName}
|
||||||
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentCancelTemplate;
|
||||||
@ -1,20 +1,8 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Container,
|
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Img,
|
|
||||||
Preview,
|
|
||||||
Section,
|
|
||||||
Tailwind,
|
|
||||||
} from '@react-email/components';
|
|
||||||
|
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
import {
|
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||||
TemplateDocumentCompleted,
|
import type { TemplateDocumentCompletedProps } from '../template-components/template-document-completed';
|
||||||
TemplateDocumentCompletedProps,
|
import { TemplateDocumentCompleted } from '../template-components/template-document-completed';
|
||||||
} from '../template-components/template-document-completed';
|
|
||||||
import { TemplateFooter } from '../template-components/template-footer';
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>;
|
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Container,
|
Container,
|
||||||
@ -10,14 +12,9 @@ import {
|
|||||||
Section,
|
Section,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
Text,
|
||||||
} from '@react-email/components';
|
} from '../components';
|
||||||
|
import type { TemplateDocumentInviteProps } from '../template-components/template-document-invite';
|
||||||
import config from '@documenso/tailwind-config';
|
import { TemplateDocumentInvite } from '../template-components/template-document-invite';
|
||||||
|
|
||||||
import {
|
|
||||||
TemplateDocumentInvite,
|
|
||||||
TemplateDocumentInviteProps,
|
|
||||||
} from '../template-components/template-document-invite';
|
|
||||||
import { TemplateFooter } from '../template-components/template-footer';
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||||
|
|||||||
@ -1,20 +1,8 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Container,
|
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Img,
|
|
||||||
Preview,
|
|
||||||
Section,
|
|
||||||
Tailwind,
|
|
||||||
} from '@react-email/components';
|
|
||||||
|
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
import {
|
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||||
TemplateDocumentPending,
|
import type { TemplateDocumentPendingProps } from '../template-components/template-document-pending';
|
||||||
TemplateDocumentPendingProps,
|
import { TemplateDocumentPending } from '../template-components/template-document-pending';
|
||||||
} from '../template-components/template-document-pending';
|
|
||||||
import { TemplateFooter } from '../template-components/template-footer';
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
export type DocumentPendingEmailTemplateProps = Partial<TemplateDocumentPendingProps>;
|
export type DocumentPendingEmailTemplateProps = Partial<TemplateDocumentPendingProps>;
|
||||||
|
|||||||
@ -1,20 +1,8 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Container,
|
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Img,
|
|
||||||
Preview,
|
|
||||||
Section,
|
|
||||||
Tailwind,
|
|
||||||
} from '@react-email/components';
|
|
||||||
|
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
import {
|
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||||
TemplateDocumentSelfSigned,
|
import type { TemplateDocumentSelfSignedProps } from '../template-components/template-document-self-signed';
|
||||||
TemplateDocumentSelfSignedProps,
|
import { TemplateDocumentSelfSigned } from '../template-components/template-document-self-signed';
|
||||||
} from '../template-components/template-document-self-signed';
|
|
||||||
import { TemplateFooter } from '../template-components/template-footer';
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
export type DocumentSelfSignedTemplateProps = TemplateDocumentSelfSignedProps;
|
export type DocumentSelfSignedTemplateProps = TemplateDocumentSelfSignedProps;
|
||||||
|
|||||||
@ -1,21 +1,9 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Container,
|
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Img,
|
|
||||||
Preview,
|
|
||||||
Section,
|
|
||||||
Tailwind,
|
|
||||||
} from '@react-email/components';
|
|
||||||
|
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||||
import { TemplateFooter } from '../template-components/template-footer';
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
import {
|
import type { TemplateForgotPasswordProps } from '../template-components/template-forgot-password';
|
||||||
TemplateForgotPassword,
|
import { TemplateForgotPassword } from '../template-components/template-forgot-password';
|
||||||
TemplateForgotPasswordProps,
|
|
||||||
} from '../template-components/template-forgot-password';
|
|
||||||
|
|
||||||
export type ForgotPasswordTemplateProps = Partial<TemplateForgotPasswordProps>;
|
export type ForgotPasswordTemplateProps = Partial<TemplateForgotPasswordProps>;
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Container,
|
Container,
|
||||||
@ -10,15 +12,10 @@ import {
|
|||||||
Section,
|
Section,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
Text,
|
Text,
|
||||||
} from '@react-email/components';
|
} from '../components';
|
||||||
|
|
||||||
import config from '@documenso/tailwind-config';
|
|
||||||
|
|
||||||
import { TemplateFooter } from '../template-components/template-footer';
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
import {
|
import type { TemplateResetPasswordProps } from '../template-components/template-reset-password';
|
||||||
TemplateResetPassword,
|
import { TemplateResetPassword } from '../template-components/template-reset-password';
|
||||||
TemplateResetPasswordProps,
|
|
||||||
} from '../template-components/template-reset-password';
|
|
||||||
|
|
||||||
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
|
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
|
||||||
|
|
||||||
|
|||||||
1
packages/lib/constants/crypto.ts
Normal file
1
packages/lib/constants/crypto.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||||
@ -10,6 +10,8 @@ import GoogleProvider from 'next-auth/providers/google';
|
|||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||||
|
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
import { ErrorCode } from './error-codes';
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
@ -25,13 +27,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
credentials: {
|
credentials: {
|
||||||
email: { label: 'Email', type: 'email' },
|
email: { label: 'Email', type: 'email' },
|
||||||
password: { label: 'Password', type: 'password' },
|
password: { label: 'Password', type: 'password' },
|
||||||
|
totpCode: {
|
||||||
|
label: 'Two-factor Code',
|
||||||
|
type: 'input',
|
||||||
|
placeholder: 'Code from authenticator app',
|
||||||
|
},
|
||||||
|
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
||||||
},
|
},
|
||||||
authorize: async (credentials, _req) => {
|
authorize: async (credentials, _req) => {
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = credentials;
|
const { email, password, backupCode, totpCode } = credentials;
|
||||||
|
|
||||||
const user = await getUserByEmail({ email }).catch(() => {
|
const user = await getUserByEmail({ email }).catch(() => {
|
||||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||||
@ -47,6 +55,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
|
||||||
|
|
||||||
|
if (is2faEnabled) {
|
||||||
|
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error(
|
||||||
|
totpCode
|
||||||
|
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
||||||
|
: ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(user.id),
|
id: Number(user.id),
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
@ -8,4 +8,15 @@ export const ErrorCode = {
|
|||||||
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
||||||
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
||||||
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
|
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
|
||||||
|
INTERNAL_SEVER_ERROR: 'INTERNAL_SEVER_ERROR',
|
||||||
|
TWO_FACTOR_ALREADY_ENABLED: 'TWO_FACTOR_ALREADY_ENABLED',
|
||||||
|
TWO_FACTOR_SETUP_REQUIRED: 'TWO_FACTOR_SETUP_REQUIRED',
|
||||||
|
TWO_FACTOR_MISSING_SECRET: 'TWO_FACTOR_MISSING_SECRET',
|
||||||
|
TWO_FACTOR_MISSING_CREDENTIALS: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||||
|
INCORRECT_TWO_FACTOR_CODE: 'INCORRECT_TWO_FACTOR_CODE',
|
||||||
|
INCORRECT_TWO_FACTOR_BACKUP_CODE: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
||||||
|
INCORRECT_IDENTITY_PROVIDER: 'INCORRECT_IDENTITY_PROVIDER',
|
||||||
|
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
|
||||||
|
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
|
||||||
|
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -20,10 +20,13 @@
|
|||||||
"@aws-sdk/cloudfront-signer": "^3.410.0",
|
"@aws-sdk/cloudfront-signer": "^3.410.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||||
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
||||||
|
"@documenso/assets": "*",
|
||||||
"@documenso/email": "*",
|
"@documenso/email": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@documenso/signing": "*",
|
"@documenso/signing": "*",
|
||||||
"@next-auth/prisma-adapter": "1.0.7",
|
"@next-auth/prisma-adapter": "1.0.7",
|
||||||
|
"@noble/ciphers": "0.4.0",
|
||||||
|
"@noble/hashes": "1.3.2",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@scure/base": "^1.1.3",
|
"@scure/base": "^1.1.3",
|
||||||
"@sindresorhus/slugify": "^2.2.1",
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
@ -31,8 +34,9 @@
|
|||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "14.0.0",
|
"next": "14.0.3",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.5",
|
||||||
|
"oslo": "^0.17.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"remeda": "^1.27.1",
|
"remeda": "^1.27.1",
|
||||||
|
|||||||
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { compare } from 'bcrypt';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { ErrorCode } from '../../next-auth/error-codes';
|
||||||
|
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||||
|
|
||||||
|
type DisableTwoFactorAuthenticationOptions = {
|
||||||
|
user: User;
|
||||||
|
backupCode: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disableTwoFactorAuthentication = async ({
|
||||||
|
backupCode,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
}: DisableTwoFactorAuthenticationOptions) => {
|
||||||
|
if (!user.password) {
|
||||||
|
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCorrectPassword = await compare(password, user.password);
|
||||||
|
|
||||||
|
if (!isCorrectPassword) {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
twoFactorBackupCodes: null,
|
||||||
|
twoFactorSecret: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { getBackupCodes } from './get-backup-code';
|
||||||
|
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||||
|
|
||||||
|
type EnableTwoFactorAuthenticationOptions = {
|
||||||
|
user: User;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enableTwoFactorAuthentication = async ({
|
||||||
|
user,
|
||||||
|
code,
|
||||||
|
}: EnableTwoFactorAuthenticationOptions) => {
|
||||||
|
if (user.identityProvider !== 'DOCUMENSO') {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.twoFactorSecret) {
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
|
||||||
|
|
||||||
|
if (!isValidToken) {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||||
|
|
||||||
|
return { recoveryCodes };
|
||||||
|
};
|
||||||
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
|
import { symmetricDecrypt } from '../../universal/crypto';
|
||||||
|
|
||||||
|
interface GetBackupCodesOptions {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZBackupCodeSchema = z.array(z.string());
|
||||||
|
|
||||||
|
export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
|
||||||
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!user.twoFactorEnabled) {
|
||||||
|
throw new Error('User has not enabled 2FA');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.twoFactorBackupCodes) {
|
||||||
|
throw new Error('User has no backup codes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = JSON.parse(secret);
|
||||||
|
|
||||||
|
const result = ZBackupCodeSchema.safeParse(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
|
|
||||||
|
type IsTwoFactorAuthenticationEnabledOptions = {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTwoFactorAuthenticationEnabled = ({
|
||||||
|
user,
|
||||||
|
}: IsTwoFactorAuthenticationEnabledOptions) => {
|
||||||
|
return (
|
||||||
|
user.twoFactorEnabled &&
|
||||||
|
user.identityProvider === 'DOCUMENSO' &&
|
||||||
|
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
|
||||||
|
);
|
||||||
|
};
|
||||||
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { base32 } from '@scure/base';
|
||||||
|
import { compare } from 'bcrypt';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { createTOTPKeyURI } from 'oslo/otp';
|
||||||
|
|
||||||
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
|
import { symmetricEncrypt } from '../../universal/crypto';
|
||||||
|
|
||||||
|
type SetupTwoFactorAuthenticationOptions = {
|
||||||
|
user: User;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ISSUER = 'Documenso';
|
||||||
|
|
||||||
|
export const setupTwoFactorAuthentication = async ({
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
}: SetupTwoFactorAuthenticationOptions) => {
|
||||||
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.identityProvider !== 'DOCUMENSO') {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCorrectPassword = await compare(password, user.password);
|
||||||
|
|
||||||
|
if (!isCorrectPassword) {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = crypto.randomBytes(10);
|
||||||
|
|
||||||
|
const backupCodes = new Array(10)
|
||||||
|
.fill(null)
|
||||||
|
.map(() => crypto.randomBytes(5).toString('hex'))
|
||||||
|
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
|
||||||
|
|
||||||
|
const accountName = user.email;
|
||||||
|
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
|
||||||
|
const encodedSecret = base32.encode(secret);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
twoFactorBackupCodes: symmetricEncrypt({
|
||||||
|
data: JSON.stringify(backupCodes),
|
||||||
|
key: key,
|
||||||
|
}),
|
||||||
|
twoFactorSecret: symmetricEncrypt({
|
||||||
|
data: encodedSecret,
|
||||||
|
key: key,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
secret: encodedSecret,
|
||||||
|
uri,
|
||||||
|
};
|
||||||
|
};
|
||||||
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { ErrorCode } from '../../next-auth/error-codes';
|
||||||
|
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||||
|
import { verifyBackupCode } from './verify-backup-code';
|
||||||
|
|
||||||
|
type ValidateTwoFactorAuthenticationOptions = {
|
||||||
|
totpCode?: string;
|
||||||
|
backupCode?: string;
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateTwoFactorAuthentication = async ({
|
||||||
|
backupCode,
|
||||||
|
totpCode,
|
||||||
|
user,
|
||||||
|
}: ValidateTwoFactorAuthenticationOptions) => {
|
||||||
|
if (!user.twoFactorEnabled) {
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.twoFactorSecret) {
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_MISSING_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totpCode) {
|
||||||
|
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupCode) {
|
||||||
|
return await verifyBackupCode({ user, backupCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
|
||||||
|
};
|
||||||
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { base32 } from '@scure/base';
|
||||||
|
import { TOTPController } from 'oslo/otp';
|
||||||
|
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
|
import { symmetricDecrypt } from '../../universal/crypto';
|
||||||
|
|
||||||
|
const totp = new TOTPController();
|
||||||
|
|
||||||
|
type VerifyTwoFactorAuthenticationTokenOptions = {
|
||||||
|
user: User;
|
||||||
|
totpCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyTwoFactorAuthenticationToken = async ({
|
||||||
|
user,
|
||||||
|
totpCode,
|
||||||
|
}: VerifyTwoFactorAuthenticationTokenOptions) => {
|
||||||
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!user.twoFactorSecret) {
|
||||||
|
throw new Error('user missing 2fa secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString(
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
|
||||||
|
|
||||||
|
return isValidToken;
|
||||||
|
};
|
||||||
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { getBackupCodes } from './get-backup-code';
|
||||||
|
|
||||||
|
type VerifyBackupCodeParams = {
|
||||||
|
user: User;
|
||||||
|
backupCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyBackupCode = async ({ user, backupCode }: VerifyBackupCodeParams) => {
|
||||||
|
const userBackupCodes = await getBackupCodes({ user });
|
||||||
|
|
||||||
|
if (!userBackupCodes) {
|
||||||
|
throw new Error('User has no backup codes');
|
||||||
|
}
|
||||||
|
|
||||||
|
return userBackupCodes.includes(backupCode);
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { hashSync as bcryptHashSync } from 'bcrypt';
|
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
||||||
|
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
import { SALT_ROUNDS } from '../../constants/auth';
|
||||||
|
|
||||||
@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth';
|
|||||||
export const hashSync = (password: string) => {
|
export const hashSync = (password: string) => {
|
||||||
return bcryptHashSync(password, SALT_ROUNDS);
|
return bcryptHashSync(password, SALT_ROUNDS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const compareSync = (password: string, hash: string) => {
|
||||||
|
return bcryptCompareSync(password, hash);
|
||||||
|
};
|
||||||
|
|||||||
88
packages/lib/server-only/document/delete-document.ts
Normal file
88
packages/lib/server-only/document/delete-document.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||||
|
|
||||||
|
export type DeleteDocumentOptions = {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
status: DocumentStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
|
||||||
|
// if the document is a draft, hard-delete
|
||||||
|
if (status === DocumentStatus.DRAFT) {
|
||||||
|
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the document is pending, send cancellation emails to all recipients
|
||||||
|
if (status === DocumentStatus.PENDING) {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await prisma.document.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.Recipient.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
document.Recipient.map(async (recipient) => {
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
const template = createElement(DocumentCancelTemplate, {
|
||||||
|
documentName: document.title,
|
||||||
|
inviterName: user.name || undefined,
|
||||||
|
inviterEmail: user.email,
|
||||||
|
assetBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: FROM_NAME,
|
||||||
|
address: FROM_ADDRESS,
|
||||||
|
},
|
||||||
|
subject: 'Document Cancelled',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the document is not a draft, only soft-delete.
|
||||||
|
return await prisma.document.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,13 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export type DeleteDraftDocumentOptions = {
|
|
||||||
id: number;
|
|
||||||
userId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteDraftDocument = async ({ id, userId }: DeleteDraftDocumentOptions) => {
|
|
||||||
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
|
|
||||||
};
|
|
||||||
@ -55,17 +55,25 @@ export const findDocuments = async ({
|
|||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: {
|
status: ExtendedDocumentStatus.COMPLETED,
|
||||||
not: ExtendedDocumentStatus.DRAFT,
|
|
||||||
},
|
|
||||||
Recipient: {
|
Recipient: {
|
||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
status: ExtendedDocumentStatus.PENDING,
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
.with(ExtendedDocumentStatus.INBOX, () => ({
|
.with(ExtendedDocumentStatus.INBOX, () => ({
|
||||||
@ -78,26 +86,29 @@ export const findDocuments = async ({
|
|||||||
signingStatus: SigningStatus.NOT_SIGNED,
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
deletedAt: null,
|
||||||
}))
|
}))
|
||||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||||
userId,
|
userId,
|
||||||
status: ExtendedDocumentStatus.DRAFT,
|
status: ExtendedDocumentStatus.DRAFT,
|
||||||
|
deletedAt: null,
|
||||||
}))
|
}))
|
||||||
.with(ExtendedDocumentStatus.PENDING, () => ({
|
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
status: ExtendedDocumentStatus.PENDING,
|
status: ExtendedDocumentStatus.PENDING,
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: ExtendedDocumentStatus.PENDING,
|
status: ExtendedDocumentStatus.PENDING,
|
||||||
|
|
||||||
Recipient: {
|
Recipient: {
|
||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
@ -106,6 +117,7 @@ export const findDocuments = async ({
|
|||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
status: ExtendedDocumentStatus.COMPLETED,
|
status: ExtendedDocumentStatus.COMPLETED,
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: ExtendedDocumentStatus.COMPLETED,
|
status: ExtendedDocumentStatus.COMPLETED,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { SigningStatus, User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
import { SigningStatus } from '@documenso/prisma/client';
|
||||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
|||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.document.groupBy({
|
prisma.document.groupBy({
|
||||||
@ -31,6 +33,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
|||||||
signingStatus: SigningStatus.NOT_SIGNED,
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.document.groupBy({
|
prisma.document.groupBy({
|
||||||
@ -39,15 +42,27 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
|||||||
_all: true,
|
_all: true,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
status: {
|
OR: [
|
||||||
not: ExtendedDocumentStatus.DRAFT,
|
{
|
||||||
},
|
status: ExtendedDocumentStatus.PENDING,
|
||||||
Recipient: {
|
Recipient: {
|
||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
|
status: ExtendedDocumentStatus.COMPLETED,
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
email: user.email,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
|||||||
throw new Error('Can not send completed document');
|
throw new Error('Can not send completed document');
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all(
|
||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
@ -95,5 +95,5 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
|||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
]);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,81 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type SearchDocumentsWithKeywordOptions = {
|
||||||
|
query: string;
|
||||||
|
userId: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchDocumentsWithKeyword = async ({
|
||||||
|
query,
|
||||||
|
userId,
|
||||||
|
limit = 5,
|
||||||
|
}: SearchDocumentsWithKeywordOptions) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documents = await prisma.document.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
contains: query,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
userId: userId,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
email: {
|
||||||
|
contains: query,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userId: userId,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
contains: query,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: DocumentStatus.PENDING,
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
contains: query,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return documents;
|
||||||
|
};
|
||||||
@ -32,7 +32,7 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
|
|||||||
|
|
||||||
const buffer = await getFile(document.documentData);
|
const buffer = await getFile(document.documentData);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all(
|
||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
const { email, name, token } = recipient;
|
const { email, name, token } = recipient;
|
||||||
|
|
||||||
@ -64,5 +64,5 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
]);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
throw new Error('Can not send completed document');
|
throw new Error('Can not send completed document');
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all(
|
||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
@ -96,7 +96,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
]);
|
);
|
||||||
|
|
||||||
const updatedDocument = await prisma.document.update({
|
const updatedDocument = await prisma.document.update({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
21
packages/lib/server-only/document/update-title.ts
Normal file
21
packages/lib/server-only/document/update-title.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type UpdateTitleOptions = {
|
||||||
|
userId: number;
|
||||||
|
documentId: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOptions) => {
|
||||||
|
return await prisma.document.update({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -37,6 +37,10 @@ export const signFieldWithToken = async ({
|
|||||||
throw new Error(`Document ${document.id} has already been completed`);
|
throw new Error(`Document ${document.id} has already been completed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.deletedAt) {
|
||||||
|
throw new Error(`Document ${document.id} has been deleted`);
|
||||||
|
}
|
||||||
|
|
||||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||||
}
|
}
|
||||||
@ -54,6 +58,7 @@ export const signFieldWithToken = async ({
|
|||||||
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
|
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
|
||||||
|
|
||||||
let customText = !isSignatureField ? value : undefined;
|
let customText = !isSignatureField ? value : undefined;
|
||||||
|
|
||||||
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
|
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
|
||||||
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
|
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
|
||||||
|
|
||||||
@ -61,29 +66,48 @@ export const signFieldWithToken = async ({
|
|||||||
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
|
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.field.update({
|
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
|
||||||
where: {
|
throw new Error('Signature field must have a signature');
|
||||||
id: field.id,
|
}
|
||||||
},
|
|
||||||
data: {
|
return await prisma.$transaction(async (tx) => {
|
||||||
customText,
|
const updatedField = await tx.field.update({
|
||||||
inserted: true,
|
where: {
|
||||||
Signature: isSignatureField
|
id: field.id,
|
||||||
? {
|
},
|
||||||
upsert: {
|
data: {
|
||||||
create: {
|
customText,
|
||||||
recipientId: field.recipientId,
|
inserted: true,
|
||||||
signatureImageAsBase64,
|
},
|
||||||
typedSignature,
|
});
|
||||||
},
|
|
||||||
update: {
|
if (isSignatureField) {
|
||||||
recipientId: field.recipientId,
|
if (!field.recipientId) {
|
||||||
signatureImageAsBase64,
|
throw new Error('Field has no recipientId');
|
||||||
typedSignature,
|
}
|
||||||
},
|
|
||||||
},
|
const signature = await tx.signature.upsert({
|
||||||
}
|
where: {
|
||||||
: undefined,
|
fieldId: field.id,
|
||||||
},
|
},
|
||||||
|
create: {
|
||||||
|
fieldId: field.id,
|
||||||
|
recipientId: field.recipientId,
|
||||||
|
signatureImageAsBase64: signatureImageAsBase64,
|
||||||
|
typedSignature: typedSignature,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
signatureImageAsBase64: signatureImageAsBase64,
|
||||||
|
typedSignature: typedSignature,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dirty but I don't want to deal with type information
|
||||||
|
Object.assign(updatedField, {
|
||||||
|
Signature: signature,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedField;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import fontkit from '@pdf-lib/fontkit';
|
|||||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CAVEAT_FONT_PATH,
|
|
||||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||||
DEFAULT_STANDARD_FONT_SIZE,
|
DEFAULT_STANDARD_FONT_SIZE,
|
||||||
MIN_HANDWRITING_FONT_SIZE,
|
MIN_HANDWRITING_FONT_SIZE,
|
||||||
@ -10,12 +9,12 @@ import {
|
|||||||
} from '@documenso/lib/constants/pdf';
|
} from '@documenso/lib/constants/pdf';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
|
||||||
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||||
// Fetch the font file from the public URL.
|
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
|
||||||
const fontResponse = await fetch(CAVEAT_FONT_PATH);
|
res.arrayBuffer(),
|
||||||
const fontCaveat = await fontResponse.arrayBuffer();
|
);
|
||||||
|
|
||||||
const isSignatureField = isSignatureFieldType(field.type);
|
const isSignatureField = isSignatureFieldType(field.type);
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export const findUsers = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [users, count] = await Promise.all([
|
const [users, count] = await Promise.all([
|
||||||
await prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
include: {
|
include: {
|
||||||
Subscription: true,
|
Subscription: true,
|
||||||
Document: {
|
Document: {
|
||||||
@ -45,7 +45,7 @@ export const findUsers = async ({
|
|||||||
skip: Math.max(page - 1, 0) * perPage,
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
take: perPage,
|
take: perPage,
|
||||||
}),
|
}),
|
||||||
await prisma.user.count({
|
prisma.user.count({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user