Compare commits

..

39 Commits
v1.1 ... v1.2.3

Author SHA1 Message Date
Mythie
b903de983b chore: v1.2.3 2023-12-02 14:56:00 +11:00
Mythie
6b519a67c2 fix: add guard 2023-12-02 14:55:26 +11:00
Mythie
39d18e93c5 chore: v1.2.2 2023-12-02 13:34:36 +11:00
Mythie
7dac5072f7 fix: revert react-email tailwind canary 2023-12-02 13:34:03 +11:00
Mythie
fbfaca190b chore: release 1.2.1 2023-12-02 12:43:55 +11:00
Mythie
486b1cbf62 fix: incorrect promise.all usages 2023-12-02 12:43:43 +11:00
Mythie
16fb90f4d2 chore: v1.2.0 2023-12-02 11:57:50 +11:00
Mythie
53cb38a394 fix: pricing page deopted into csr 2023-12-02 11:14:46 +11:00
Mythie
073a050587 fix: signature field race conditions 2023-12-02 11:09:42 +11:00
Lucas Smith
39c01f4e8d fix: remove server actions (#684) 2023-12-02 09:38:24 +11:00
Lucas Smith
335684d0b7 fix: edit document sizing (#706) 2023-12-01 23:09:24 +11:00
Nafees Nazik
792158c2cb feat: add two factor auth (#643)
Add two factor authentication for users who wish to enhance the security of their accounts.
2023-12-01 20:06:32 +11:00
Lucas Smith
83153cee32 Merge pull request #698 from cuttingedge1109/patch-1
fix: Fix typo in web build command in doc
2023-12-01 15:06:36 +11:00
Lucas Smith
2d2bdc536e fix: add a script for db seed with env (#700) 2023-12-01 12:12:15 +11:00
cuttingedge1109
c16c36a1fc fix: add a script for db seed with env 2023-11-30 18:38:54 +01:00
david-loe
1d79ebbda3 fix: body exeeded undefined limit (#679)
* fixed bodySizeLimit

* fix: update marketing config

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2023-11-30 09:46:33 +02:00
Bilal Ahmad Bhat
252dd0008c feat: add link to homepage on the complete sign page for logged in users (#691)
* feat: add link to homepage on the complete sign page for logged in users

* feat: added ChevronLeft icon to the link

* feat: remove icon from the link
2023-11-30 09:42:15 +02:00
cuttingedge1109
35d0fed8b3 fix: Fix typo in web build command in doc 2023-11-29 20:06:25 +01:00
Tanay
dad56b4929 fix: minor in file extension (#694) 2023-11-29 09:11:29 +11:00
Szymon Sus
7e4c44e820 perf(web, lib): do not await inside promise statements (#692) 2023-11-29 09:10:15 +11:00
Tanay
adc97802ea feat: add/update title of the document (#663) 2023-11-28 14:56:50 +11:00
Mythie
8048c29480 fix: override @react-email/tailwind to avoid perf regression 2023-11-24 23:57:34 +11:00
Mythie
84b958d5b7 fix: universal upload hitting cache 2023-11-24 20:06:47 +11:00
Mythie
d8688692f7 fix: move singleplayer create to trpc 2023-11-24 16:58:18 +11:00
Mythie
8230349114 fix: unable to load font for signing 2023-11-24 16:17:54 +11:00
Mythie
c054fc78a4 fix: resolve issues with emailVerified jwt property 2023-11-23 15:11:37 +11:00
Mythie
5de0c464f0 fix: hydration errors for modifier key 2023-11-23 13:57:08 +11:00
Lucas Smith
9444e0cc67 fix: docker build requires smtp host (#672)
set a default for smtp host and add an action
for testing docker builds on each pull request
2023-11-22 16:26:39 +11:00
Lucas Smith
be0fe079a3 fix: add healthcheck endpoint (#671) 2023-11-22 15:46:21 +11:00
Catalin Pit
fbbc3b89c3 feat: email verification for registration (#599) 2023-11-21 15:44:04 +11:00
Bilal Ahmad Bhat
6c73453542 #666 feat: disabled resend button for recipients (#667) 2023-11-20 21:53:57 +11:00
Anik Dhabal Babu
17eeaa2d25 fix: improve the validation message for documenso app (#640)
* fix: improve the validation message

* fix: improve the validation message

---------

Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2023-11-20 12:23:27 +02:00
Catalin Pit
a8d49bb8b8 chore: added Documenso video walkthrough (#665) 2023-11-20 19:01:34 +11:00
Anupam
e077c36fe4 fix: added the zod validation msg in the single player mode (#646) 2023-11-17 14:28:42 +02:00
Adithya Krishna
7ce4cf8381 feat: add dark mode toggle (#529) 2023-11-17 17:01:39 +11:00
Ephraim Atta-Duncan
cebdf5fd8e chore: custom command menu shortcut text for macOS (#657) 2023-11-17 16:47:19 +11:00
Ephraim Atta-Duncan
8adc44802f feat: copy signing link from avatar stack (#658) 2023-11-17 16:12:47 +11:00
Ephraim Atta-Duncan
06714a2aeb chore: append _signed to files when downloading (#656) 2023-11-17 12:02:22 +11:00
Mythie
1c9cec1e93 fix: remove plausible provider 2023-11-17 10:55:09 +11:00
137 changed files with 5795 additions and 3802 deletions

View File

@@ -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=""

View File

@@ -14,8 +14,8 @@ env:
HUSKY: 0 HUSKY: 0
jobs: jobs:
build: build_app:
name: Build name: Build App
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -37,3 +37,16 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Build Docker Image
run: ./docker/build.sh

View File

@@ -193,6 +193,12 @@ git clone https://github.com/documenso/documenso
We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso) We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso)
### Video walkthrough
If you're a visual learner and prefer to watch a video walkthrough of setting up Documenso locally, check out this video:
[![Watch the video](https://img.youtube.com/vi/Y0ppIQrEnZs/hqdefault.jpg)](https://youtu.be/Y0ppIQrEnZs)
## Docker ## Docker
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso. 🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
@@ -234,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
``` ```

View File

@@ -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': {

View File

@@ -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": {
@@ -13,6 +13,7 @@
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
}, },
"dependencies": { "dependencies": {
"@documenso/assets": "*",
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",
"@documenso/trpc": "*", "@documenso/trpc": "*",
@@ -23,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",
@@ -43,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"
}
} }
} }

View File

@@ -1,3 +1,5 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { import {

View File

@@ -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',

View File

@@ -5,14 +5,13 @@ import type { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { FaXTwitter } from 'react-icons/fa6'; import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia'; import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu'; import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png'; import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
export type FooterProps = HTMLAttributes<HTMLDivElement>; export type FooterProps = HTMLAttributes<HTMLDivElement>;
@@ -36,8 +35,6 @@ const FOOTER_LINKS = [
]; ];
export const Footer = ({ className, ...props }: FooterProps) => { export const Footer = ({ className, ...props }: FooterProps) => {
const { setTheme } = useTheme();
return ( return (
<div className={cn('border-t py-12', className)} {...props}> <div className={cn('border-t py-12', className)} {...props}>
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8"> <div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
@@ -79,21 +76,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
))} ))}
</div> </div>
</div> </div>
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24"> <div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-8 md:mt-12 lg:mt-24">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. All rights reserved. © {new Date().getFullYear()} Documenso, Inc. All rights reserved.
</p> </p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5"> <div className="flex flex-wrap">
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}> <ThemeSwitcher />
<Sun className="h-5 w-5" />
<span className="sr-only">Light</span>
</button>
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
<Moon className="h-5 w-5" />
<span className="sr-only">Dark</span>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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,
};
};

View File

@@ -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 }),

View File

@@ -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': {

View File

@@ -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": {
@@ -13,6 +13,7 @@
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
}, },
"dependencies": { "dependencies": {
"@documenso/assets": "*",
"@documenso/ee": "*", "@documenso/ee": "*",
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
@@ -26,8 +27,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",
@@ -43,6 +44,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": {
@@ -51,5 +53,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"
}
} }
} }

View File

@@ -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;

View File

@@ -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}

View File

@@ -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,
}), }),

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { History } from 'lucide-react'; import { History } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
@@ -54,11 +55,14 @@ export const ResendDocumentActionItem = ({
document, document,
recipients, recipients,
}: ResendDocumentActionItemProps) => { }: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
const { toast } = useToast(); const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
const isDisabled = const isDisabled =
!isOwner ||
document.status !== 'PENDING' || document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED); !recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);

View File

@@ -62,9 +62,10 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
}); });
const link = window.document.createElement('a'); const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob); link.href = window.URL.createObjectURL(blob);
link.download = row.title || 'document.pdf'; link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click(); link.click();

View File

@@ -88,9 +88,10 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
}); });
const link = window.document.createElement('a'); const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob); link.href = window.URL.createObjectURL(blob);
link.download = row.title || 'document.pdf'; link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click(); link.click();

View File

@@ -6,8 +6,8 @@ import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react'; 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 { FindResultSet } from '@documenso/lib/types/find-result-set'; import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { Document, Recipient, User } from '@documenso/prisma/client'; import type { Document, Recipient, User } from '@documenso/prisma/client';
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';

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
@@ -22,6 +23,7 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => { export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter(); const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast(); const { toast } = useToast();
@@ -79,7 +81,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
<DocumentDropzone <DocumentDropzone
className="min-h-[40vh]" className="min-h-[40vh]"
disabled={remaining.documents === 0} disabled={remaining.documents === 0 || !session?.user.emailVerified}
onDrop={onFileDrop} onDrop={onFileDrop}
/> />

View File

@@ -9,6 +9,7 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Header } from '~/components/(dashboard)/layout/header'; import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth'; import { NextAuthProvider } from '~/providers/next-auth';
@@ -30,6 +31,7 @@ export default async function AuthenticatedDashboardLayout({
return ( return (
<NextAuthProvider session={session}> <NextAuthProvider session={session}>
<LimitsProvider> <LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Header user={user} /> <Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main> <main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@@ -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 && (

View File

@@ -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>
);
} }

View File

@@ -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>

View 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>
);
}

View File

@@ -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 */}
@@ -105,15 +109,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>
); );

View File

@@ -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 },

View File

@@ -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,
}); });

View File

@@ -0,0 +1,97 @@
import Link from 'next/link';
import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { Button } from '@documenso/ui/primitives/button';
export type PageProps = {
params: {
token: string;
};
};
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
if (!token) {
return (
<div className="w-full">
<div className="mb-4 text-red-300">
<XOctagon />
</div>
<h2 className="text-4xl font-semibold">No token provided</h2>
<p className="text-muted-foreground mt-2 text-base">
It seems that there is no token provided. Please check your email and try again.
</p>
</div>
);
}
const verified = await verifyEmail({ token });
if (verified === null) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
<p className="text-muted-foreground mt-4">
We were unable to verify your email. If your email is not verified already, please try
again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
if (!verified) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
<p className="text-muted-foreground mt-4">
It seems that the provided token has expired. We've just sent you another token, please
check your email and try again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
<p className="text-muted-foreground mt-4">
Your email has been successfully confirmed! You can now use all features of Documenso.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import Link from 'next/link';
import { XCircle } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
<p className="text-muted-foreground mt-4">
It seems that there is no token provided, if you are trying to verify your email please
follow the link in your email.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

@@ -12,7 +12,6 @@ import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { ThemeProvider } from '~/providers/next-theme'; import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog'; import { PostHogPageview } from '~/providers/posthog';
import './globals.css'; import './globals.css';
@@ -69,13 +68,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<body> <body>
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<FeatureFlagProvider initialFlags={flags}> <FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <TooltipProvider>
<TooltipProvider> <TrpcProvider>{children}</TrpcProvider>
<TrpcProvider>{children}</TrpcProvider> </TooltipProvider>
</TooltipProvider> </ThemeProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster /> <Toaster />
</FeatureFlagProvider> </FeatureFlagProvider>

View File

@@ -0,0 +1,46 @@
'use client';
import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: Recipient;
};
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
const onRecipientClick = () => {
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.',
});
});
};
return (
<div className="my-1 flex cursor-pointer items-center gap-2" onClick={onRecipientClick}>
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span
className="text-muted-foreground text-sm hover:underline"
title="Click to copy signing link for sending to recipient"
>
{recipient.email}
</span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -8,6 +8,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@documenso/ui/primitives/tooltip'; } from '@documenso/ui/primitives/tooltip';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars'; import { StackAvatars } from './stack-avatars';
@@ -68,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>
)} )}
@@ -85,15 +78,7 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Opened</h1> <h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => ( {openedRecipients.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>
)} )}
@@ -102,15 +87,7 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Uncompleted</h1> <h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => ( {uncompletedRecipients.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>
)} )}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { HTMLAttributes, useState } from 'react'; import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
@@ -14,6 +15,14 @@ export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
// const pathname = usePathname(); // const pathname = usePathname();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
useEffect(() => {
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
setModifierKey(isMacOS ? '⌘' : 'Ctrl');
}, []);
return ( return (
<div <div
@@ -33,8 +42,8 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</div> </div>
<div> <div>
<div className="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 font-mono text-xs"> <div className="text-muted-foreground bg-muted flex items-center rounded-md px-1.5 py-0.5 text-xs tracking-wider">
Ctrl+K {modifierKey}+K
</div> </div>
</div> </div>
</Button> </Button>

View File

@@ -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,
@@ -87,9 +87,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>

View File

@@ -0,0 +1,123 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { ONE_SECOND } from '@documenso/lib/constants/time';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type VerifyEmailBannerProps = {
email: string;
};
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
const { mutateAsync: sendConfirmationEmail, isLoading } =
trpc.profile.sendConfirmationEmail.useMutation();
const onResendConfirmationEmail = async () => {
try {
setIsButtonDisabled(true);
await sendConfirmationEmail({ email: email });
toast({
title: 'Success',
description: 'Verification email sent successfully.',
});
setIsOpen(false);
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
} catch (err) {
setIsButtonDisabled(false);
toast({
title: 'Error',
description: 'Something went wrong while sending the confirmation email.',
variant: 'destructive',
});
}
};
useEffect(() => {
// Check localStorage to see if we've recently automatically displayed the dialog
// if it was within the past 24 hours, don't show it again
// otherwise, show it again and update the localStorage timestamp
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
return;
}
}
setIsOpen(true);
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
}, []);
return (
<>
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
Verify your email address to unlock all features.
</div>
<div>
<Button
variant="ghost"
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
disabled={isButtonDisabled}
onClick={() => setIsOpen(true)}
size="sm"
>
{isButtonDisabled ? 'Verification Email Sent' : 'Verify Now'}
</Button>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogTitle>Verify your email address</DialogTitle>
<DialogDescription>
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox and
click the link in the email to verify your account.
</DialogDescription>
<div>
<Button
disabled={isButtonDisabled}
loading={isLoading}
onClick={onResendConfirmationEmail}
>
{isLoading ? 'Sending...' : 'Resend Confirmation Email'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react'; import { 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>

View File

@@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react'; import { 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>

View 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)}
/>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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}
/>
</>
);
};

View 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>
);
};

View File

@@ -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,
})),
});
};

View File

@@ -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,
})),
});
};

View File

@@ -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,
});
};

View File

@@ -20,9 +20,18 @@ import { FormErrorMessage } from '../form/form-error-message';
export const ZPasswordFormSchema = z export const ZPasswordFormSchema = z
.object({ .object({
currentPassword: z.string().min(6).max(72), currentPassword: z
password: z.string().min(6).max(72), .string()
repeatedPassword: z.string().min(6).max(72), .min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
password: z
.string()
.min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
repeatedPassword: z
.string()
.min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
}) })
.refine((data) => data.password === data.repeatedPassword, { .refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match', message: 'Passwords do not match',

View File

@@ -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).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>
); );
}; };

View File

@@ -21,7 +21,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignUpFormSchema = z.object({ export const ZSignUpFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(6).max(72), password: z
.string()
.min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
signature: z.string().min(1, { message: 'We need your signature to sign documents' }), signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
}); });
@@ -134,6 +137,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
)} )}
</Button> </Button>
</div> </div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div> </div>
<div> <div>

View File

@@ -0,0 +1,21 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@documenso/prisma';
export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
try {
await prisma.$queryRaw`SELECT 1`;
return res.json({
status: 'ok',
message: 'All systems operational',
});
} catch (err) {
console.error(err);
return res.status(500).json({
status: 'error',
message: err.message,
});
}
}

View File

@@ -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 }),

5478
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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": {},
"react-hotkeys-hook": "^4.4.1", "overrides": {
"recharts": "^2.7.2" "next-auth": {
"next": "14.0.3"
},
"next-contentlayer": {
"next": "14.0.3"
}
} }
} }

View File

@@ -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"

View 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';

View File

@@ -42,12 +42,8 @@ const getTransport = () => {
}); });
} }
if (!process.env.NEXT_PRIVATE_SMTP_HOST) {
throw new Error('SMTP transport requires NEXT_PRIVATE_SMTP_HOST');
}
return createTransport({ return createTransport({
host: process.env.NEXT_PRIVATE_SMTP_HOST, host: process.env.NEXT_PRIVATE_SMTP_HOST ?? 'localhost:2500',
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587, port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true', secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
auth: { auth: {

View File

@@ -17,11 +17,27 @@
"worker:test": "tsup worker/index.ts --format esm" "worker:test": "tsup worker/index.ts --format esm"
}, },
"dependencies": { "dependencies": {
"@documenso/nodemailer-resend": "1.0.0", "@documenso/nodemailer-resend": "2.0.0",
"@react-email/components": "^0.0.7", "@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.4", "react-email": "^1.9.5",
"resend": "^1.1.0" "resend": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",

View File

@@ -1 +1 @@
export { render } from '@react-email/components'; export { render, renderAsync } from '@react-email/render';

View File

@@ -0,0 +1,41 @@
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateConfirmationEmailProps = {
confirmationLink: string;
assetBaseUrl: string;
};
export const TemplateConfirmationEmail = ({
confirmationLink,
assetBaseUrl,
}: TemplateConfirmationEmailProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
Welcome to Documenso!
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Before you get started, please confirm your email address by clicking the button below:
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={confirmationLink}
>
Confirm email
</Button>
<Text className="mt-8 text-center text-sm italic text-slate-400">
You can also copy and paste this link into your browser: {confirmationLink} (link
expires in 1 hour)
</Text>
</Section>
</Section>
</>
);
};

View File

@@ -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> </>
); );
}; };

View File

@@ -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;

View File

@@ -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> </>
); );
}; };

View File

@@ -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> </>
); );
}; };

View File

@@ -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> </>
); );
}; };

View File

@@ -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;

View File

@@ -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> </>
); );
}; };

View File

@@ -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> </>
); );
}; };

View File

@@ -0,0 +1,57 @@
import config from '@documenso/tailwind-config';
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
import type { TemplateConfirmationEmailProps } from '../template-components/template-confirmation-email';
import { TemplateConfirmationEmail } from '../template-components/template-confirmation-email';
import { TemplateFooter } from '../template-components/template-footer';
export const ConfirmEmailTemplate = ({
confirmationLink,
assetBaseUrl,
}: TemplateConfirmationEmailProps) => {
const previewText = `Please confirm your email address`;
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"
/>
<TemplateConfirmationEmail
confirmationLink={confirmationLink}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Tailwind>
</Html>
);
};

View File

@@ -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>;

View File

@@ -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> & {

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -0,0 +1 @@
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;

View File

@@ -1,12 +1,17 @@
/// <reference types="../types/next-auth.d.ts" />
import { PrismaAdapter } from '@next-auth/prisma-adapter'; import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { compare } from 'bcrypt'; import { compare } from 'bcrypt';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AuthOptions, Session, User } from 'next-auth'; import type { AuthOptions, Session, User } from 'next-auth';
import type { JWT } from 'next-auth/jwt';
import CredentialsProvider from 'next-auth/providers/credentials'; import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google'; import type { GoogleProfile } from 'next-auth/providers/google';
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';
@@ -22,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);
@@ -44,10 +55,25 @@ 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,
name: user.name, name: user.name,
emailVerified: user.emailVerified?.toISOString() ?? null,
} satisfies User; } satisfies User;
}, },
}), }),
@@ -61,6 +87,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
id: Number(profile.sub), id: Number(profile.sub),
name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(), name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(),
email: profile.email, email: profile.email,
emailVerified: profile.email_verified ? new Date().toISOString() : null,
}; };
}, },
}), }),
@@ -70,9 +97,10 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const merged = { const merged = {
...token, ...token,
...user, ...user,
}; emailVerified: user?.emailVerified ? new Date(user.emailVerified).toISOString() : null,
} satisfies JWT;
if (!merged.email) { if (!merged.email || typeof merged.emailVerified !== 'string') {
const userId = Number(merged.id ?? token.sub); const userId = Number(merged.id ?? token.sub);
const retrieved = await prisma.user.findFirst({ const retrieved = await prisma.user.findFirst({
@@ -88,6 +116,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
merged.id = retrieved.id; merged.id = retrieved.id;
merged.name = retrieved.name; merged.name = retrieved.name;
merged.email = retrieved.email; merged.email = retrieved.email;
merged.emailVerified = retrieved.emailVerified?.toISOString() ?? null;
} }
if ( if (
@@ -97,7 +126,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
) { ) {
merged.lastSignedIn = new Date().toISOString(); merged.lastSignedIn = new Date().toISOString();
await prisma.user.update({ const user = await prisma.user.update({
where: { where: {
id: Number(merged.id), id: Number(merged.id),
}, },
@@ -105,6 +134,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
lastSignedIn: merged.lastSignedIn, lastSignedIn: merged.lastSignedIn,
}, },
}); });
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
} }
return { return {
@@ -112,7 +143,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
name: merged.name, name: merged.name,
email: merged.email, email: merged.email,
lastSignedIn: merged.lastSignedIn, lastSignedIn: merged.lastSignedIn,
}; emailVerified: merged.emailVerified,
} satisfies JWT;
}, },
session({ token, session }) { session({ token, session }) {
@@ -123,6 +155,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
id: Number(token.id), id: Number(token.id),
name: token.name, name: token.name,
email: token.email, email: token.email,
emailVerified: token.emailVerified ?? null,
}, },
} satisfies Session; } satisfies Session;
} }

View File

@@ -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;

View File

@@ -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",

View 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;
};

View 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 };
};

View 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;
};

View 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'
);
};

View 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,
};
};

View 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);
};

View 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;
};

View 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);
};

View File

@@ -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);
};

View File

@@ -0,0 +1,56 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma';
export interface SendConfirmationEmailProps {
userId: number;
}
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
VerificationToken: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
const [verificationToken] = user.VerificationToken;
if (!verificationToken?.token) {
throw new Error('Verification token not found for the user');
}
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,
confirmationLink,
});
return mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: {
name: senderName,
address: senderAdress,
},
subject: 'Please confirm your email',
html: render(confirmationTemplate),
text: render(confirmationTemplate, { plainText: true }),
});
};

View File

@@ -2,10 +2,11 @@ import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { Document, Prisma, SigningStatus } from '@documenso/prisma/client'; import type { Document, Prisma } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { FindResultSet } from '../../types/find-result-set'; import type { FindResultSet } from '../../types/find-result-set';
export interface FindDocumentsOptions { export interface FindDocumentsOptions {
userId: number; userId: number;
@@ -160,19 +161,11 @@ export const findDocuments = async ({
}), }),
]); ]);
const maskedData = data.map((doc) => ({
...doc,
Recipient: doc.Recipient.map((recipient) => ({
...recipient,
token: recipient.email === user.email ? recipient.token : '',
})),
}));
return { return {
data: maskedData, data,
count, count,
currentPage: Math.max(page, 1), currentPage: Math.max(page, 1),
perPage, perPage,
totalPages: Math.ceil(count / perPage), totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof maskedData>; } satisfies FindResultSet<typeof data>;
}; };

View File

@@ -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 }),
}); });
}), }),
]); );
}; };

View File

@@ -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) =>
], ],
}); });
}), }),
]); );
}; };

View File

@@ -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: {

View 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,
},
});
};

View File

@@ -54,6 +54,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 +62,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;
}); });
}; };

View File

@@ -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);

View File

@@ -0,0 +1,41 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
const IDENTIFIER = 'confirmation-email';
export const generateConfirmationToken = async ({ email }: { email: string }) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
where: {
email: email,
},
});
if (!user) {
throw new Error('User not found');
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {
connect: {
id: user.id,
},
},
},
});
if (!createdToken) {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
};

View File

@@ -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,
}), }),
]); ]);

View File

@@ -0,0 +1,41 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
const IDENTIFIER = 'confirmation-email';
export const sendConfirmationToken = async ({ email }: { email: string }) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
where: {
email: email,
},
});
if (!user) {
throw new Error('User not found');
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {
connect: {
id: user.id,
},
},
},
});
if (!createdToken) {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
};

View File

@@ -0,0 +1,70 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { sendConfirmationToken } from './send-confirmation-token';
export type VerifyEmailProps = {
token: string;
};
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
const verificationToken = await prisma.verificationToken.findFirst({
include: {
user: true,
},
where: {
token,
},
});
if (!verificationToken) {
return null;
}
// check if the token is valid or expired
const valid = verificationToken.expires > new Date();
if (!valid) {
const mostRecentToken = await prisma.verificationToken.findFirst({
where: {
userId: verificationToken.userId,
},
orderBy: {
createdAt: 'desc',
},
});
// If there isn't a recent token or it's older than 1 hour, send a new token
if (
!mostRecentToken ||
DateTime.now().minus({ hours: 1 }).toJSDate() > mostRecentToken.createdAt
) {
await sendConfirmationToken({ email: verificationToken.user.email });
}
return valid;
}
const [updatedUser, deletedToken] = await prisma.$transaction([
prisma.user.update({
where: {
id: verificationToken.userId,
},
data: {
emailVerified: new Date(),
},
}),
prisma.verificationToken.deleteMany({
where: {
userId: verificationToken.userId,
},
}),
]);
if (!updatedUser || !deletedToken) {
throw new Error('Something went wrong while verifying your email. Please try again.');
}
return !!updatedUser && !!deletedToken;
};

Some files were not shown because too many files have changed in this diff Show More