Compare commits

..

36 Commits

Author SHA1 Message Date
8be52e2fa3 fix: final reference to created column 2023-09-14 14:50:17 +10:00
0d702e9189 fix: remove further references to created column 2023-09-14 13:37:38 +10:00
425db8fc1f fix: remove references to created column 2023-09-14 13:32:16 +10:00
2356f58e7b fix: implement feedback 2023-09-14 13:21:03 +10:00
6c12ed4afc fix: update migration for timestamp columns 2023-09-14 13:07:55 +10:00
d76ee7f33c Merge branch 'feat/refresh' into feat/universal-upload 2023-09-14 12:53:58 +10:00
f8534b2c3d fix: add dashboard header border on scroll 2023-09-14 12:51:59 +10:00
9014f01276 feat: universal upload
Implementation of a universal upload allowing for multiple storage backends
starting with `database` and `s3`.

Allows clients to put and retrieve files from either client or server using
a blend of client and server actions.
2023-09-14 12:47:47 +10:00
b255eb21e5 Merge pull request #369 from documenso/fix/building-documenso-description
fix: update building documenso article description
2023-09-13 14:45:24 +02:00
9a58178ea5 Merge branch 'feat/refresh' into fix/building-documenso-description 2023-09-13 14:42:41 +02:00
3c36eedfba chore: phrasing 2023-09-13 14:42:27 +02:00
46dfaa70a3 Update apps/marketing/content/blog/building-documenso-pt1.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2023-09-13 14:39:01 +02:00
61da354a48 Merge pull request #361 from documenso/feat/admin-ui-metrics
feat: admin ui for metrics
2023-09-13 21:55:09 +10:00
fbb332fb35 Merge branch 'feat/refresh' into feat/admin-ui-metrics 2023-09-13 21:54:33 +10:00
7e1cce9155 Merge pull request #365 from documenso/feat/avatar-fallback
feat: add avatar email fallback
2023-09-13 21:51:42 +10:00
ed4cbe9fa6 Merge branch 'feat/refresh' into feat/universal-upload 2023-09-12 20:51:31 +10:00
599e857a1e fix: add removed layout guard 2023-09-12 17:53:38 +10:00
581f08c59b fix: update layout and wording 2023-09-12 07:25:44 +00:00
24a2e9e6d4 feat: update document table layout (#371)
* feat: update document table layout

- Removed dashboard page
- Removed redundant ID column
- Moved date to first column
- Added estimated locales for SSR dates
2023-09-12 14:29:27 +10:00
db3f75c42f fix: data table links for recipients 2023-09-12 10:38:23 +10:00
e8b5b3b24a fix: update building documenso article description 2023-09-11 15:22:09 +02:00
00574325b9 chore: implemented feedback 2023-09-11 13:43:17 +03:00
99706e0ed6 chore: fix version in nextjs config 2023-09-11 11:34:10 +03:00
326743d8a1 chore: added app version 2023-09-11 10:59:50 +03:00
3f67b0f27e Merge pull request #292 from documenso/feat/blog-post-next
fix: typo in blog post
2023-09-11 17:09:31 +10:00
24036b0f24 fix typo 2023-09-11 17:03:14 +10:00
898f5a629c Merge branch 'feat/refresh' into feat/admin-ui-metrics 2023-09-09 15:49:56 +10:00
27edcebef6 Merge branch 'feat/refresh' into feat/admin-ui-metrics 2023-09-09 15:44:34 +10:00
5862af3034 Merge branch 'feat/refresh' into feat/admin-ui-metrics 2023-09-09 15:16:03 +10:00
5969f148c8 chore: changed the cards titles 2023-09-08 14:51:55 +03:00
660f5894a6 chore: feedback improvements 2023-09-08 12:56:44 +03:00
77058220a8 chore: rename files 2023-09-08 12:42:14 +03:00
6cdba45396 chore: implemented feedback 2023-09-08 12:39:13 +03:00
67571158e8 feat: add the admin page 2023-09-08 11:28:50 +03:00
171a5ba4ee feat: creating the admin ui for metrics 2023-09-08 09:16:31 +03:00
a2ef9468ae feat: separate document data from document 2023-09-07 19:27:21 +10:00
76 changed files with 3198 additions and 568 deletions

View File

@ -15,6 +15,20 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT=
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION=
# REQUIRED: Defines the bucket to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_BUCKET=
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
# [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"

View File

@ -1,98 +1,98 @@
---
title: 'Building Documenso — Part 1: Certificates'
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2023-06-23
tags:
- Open Source
- Document Signature
- Certificates
- Signing
---
<figure>
<MdxNextImage
src="/blog/blog-banner-building-documenso.webp"
width="1200"
height="675"
alt="Building Documenso blog banner"
/>
<figcaption className="text-center">
What actually is a signature?
</figcaption>
</figure>
> Disclaimer: Im not a lawyer and this isnt legal advice. We plan to publish a much more specific framework on the topic of signature validity.
This is the first installment of the new Building Documenso series, where I describe the challenges and design choices that we make while building the worlds most open signing platform.
As you may have heard, we launched the community-reviewed <a href="https://github.com/documenso/documenso" target="_blank">version 0.9 of Documenso on GitHub</a> recently and its now available through the early adopters plan. One of the most fundamental choices we had to make on this first release, was the choice of certificate. While its interesting to know what we opted for, this shall also serve as a guide for everyone facing the same choice for self-hosting Documenso.
> Question: Why do I need a document signing certificate to self-host?
>
> Short Answer: Inserting the images of a signature into the document is only part of the signing process.
To have an actual digitally signed document you need a document signing certificate that is used to create the digital signature that is inserted into the document, alongside the visible one¹.
When hosting a signature service yourself, as we do, there are four main choices for handling the certificate: Not using a certificate, creating your own, buying a trusted certificate, and becoming and trusted service provider to issue your own trusted certificate.
## 1\. No Certificate
A lot of signing services actually dont employ actual digital signatures besides the inserted image. The only insert and image of the signatures into the document you sign. This can be done and is legally acceptable in many cases. This option isnt directly supported by Documenso without changing the code.
## 2\. Create your own
Since the cryptography behind certificates is freely available as open source you could generate your own using OpenSSL for example. Since its hardly more work than option 1 (using Documenso at least), this would be my minimum effort recommendation. Having a self-created (“self-signed”) certificate doesnt add much in terms of regulation but it guarantees the documents integrity, meaning no changes have been made after signing². What this doesnt give you, is the famous green checkmark in Adobe Acrobat. Why? Because you arent on the list of providers Adobe “trusts”.³
## 3\. Buy a “trusted” certificate.
There are Certificate Authorities (CAs) that can sell you a certificate⁴. The service they provide is, that they validate your name (personal certificates) or your organizations name (corporate certificate) before creating your certificate for you, just like you did in option 2. The difference is, that they are listed on the previously mentioned trust lists (e.g. Adobes) and thus the resulting signatures get a nice, green checkmark in Adobe Reader⁵
## 4\. Becoming a Trusted Certificate Authority (CA) yourself and create your own certificate
This option is an incredibly complex endeavour, requiring a lot of effort and skill. It can be done, as there are multiple CAs around the world. Is it worth the effort? That depends a lot on what youre trying to accomplish.
<center>.&nbsp;&nbsp;.&nbsp;&nbsp;.</center>
## What we did
Having briefly introduced the options, here is what we did: Since we aim to raise the bar on digital signature proliferation and trust, we opted to buy an “Advanced Personal Certificates for Companies/Organisations” from WiseKey. Thus, documents signed with Documensos hosted version look like this:
<figure>
<MdxNextImage
src="/blog/blog-fig-building-documenso.webp"
width="1262"
height="481"
alt="Figure 1"
/>
<figcaption className="text-center">The famous green checkmark: Signed by hosted Documenso</figcaption>
</figure>
There werent any deeper reasons we choose WiseKey, other than they offered what we needed and there wasnt any reason to look much further. While I didnt map the entire certificate market offering (yet), Im pretty sure something similar could be found elsewhere. While we opted for option 3, choosing option 2 might be perfectly reasonable considering your use case.⁶
> While this is our setup, for now, we have a bigger plan for this topic. While globally trusted SSL Certificates have been available for free, courtesy of Lets Encrypt, for a while now, there is no such thing as document signing. And there should be. Not having free and trusted infrastructure for signing is blocking a completely new generation of signing products from being created. This is why well start working on option 4 when the time is right.
Do you have questions or thoughts about this? As always, let me know in the comments, on <a href="http://twitter.com/eltimuro" target="_blank">twitter.com/eltimuro</a>
or directly: <a href="https://documen.so/timur" target="_blank">documen.so/timur</a>
Join the self-hoster community here: <a href="https://documenso.slack.com/" target="_blank">https://documenso.slack.com/</a>
Best from Hamburg
Timur
\[1\] There are different approaches to signing a document. For the sake of simplicity, here we talk about a document with X inserted signature images, that is afterward signed once the by signing service, i.e. Documenso. If each visual signature should have its own digital one (e.g. QES — eIDAS Level 3), the case is a bit more complex.
\[2\] Of course, the signing service provider technically can change and resign the document, especially in the case mentioned in \[1\]. This can be countered by requiring actual digital signatures from each signer, that are bound to their identity/ account. Creating a completely trustless system in the context however is extremely hard to do and not the most pressing business need for the industry at this point, in my opinion. Though, this would be nice.
\[3\] Adobe, like the EU, has a list of organizations they trust. The Adobe green checkmark is powered by the Adobe trust list, if you want to be trusted by EU standards here: <a href="https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation" target="_blank">https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation</a>, you need to be on the EU trust list. Getting on each list is possible, though the latter is much more work.
\[4\] Technically, they sign your certificate creation request (created by you), containing your info with their certificate (which is trusted), making your certificate trusted. This way, everything you sign with your certificate is seen as trusted. They created their certificate just like you, the difference is they are on the lists, mentioned in \[3\]
\[5\] Why does Adobe get to say, what is trusted? They simply happen to have the most used pdf viewer. And since everyone checks there, whom they consider trusted carries weight. If it should be like this, is a different matter.
\[6\] Self-Signed signatures, even purely visual signatures, are fully legally binding. Why you use changes mainly your confidence in the signature and the burden of proof. Also, some industries require a certain level of signatures e.g. retail loans (QES/ eIDAS Level 3 in the EU).
---
title: 'Building Documenso — Part 1: Certificates'
description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the worlds most open signing platform.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2023-06-23
tags:
- Open Source
- Document Signature
- Certificates
- Signing
---
<figure>
<MdxNextImage
src="/blog/blog-banner-building-documenso.webp"
width="1200"
height="675"
alt="Building Documenso blog banner"
/>
<figcaption className="text-center">
What actually is a signature?
</figcaption>
</figure>
> Disclaimer: Im not a lawyer and this isnt legal advice. We plan to publish a much more specific framework on the topic of signature validity.
This is the first installment of the new Building Documenso series, where I describe the challenges and design choices that we make while building the worlds most open signing platform.
As you may have heard, we launched the community-reviewed <a href="https://github.com/documenso/documenso" target="_blank">version 0.9 of Documenso on GitHub</a> recently and its now available through the early adopters plan. One of the most fundamental choices we had to make on this first release, was the choice of certificate. While its interesting to know what we opted for, this shall also serve as a guide for everyone facing the same choice for self-hosting Documenso.
> Question: Why do I need a document signing certificate to self-host?
>
> Short Answer: Inserting the images of a signature into the document is only part of the signing process.
To have an actual digitally signed document you need a document signing certificate that is used to create the digital signature that is inserted into the document, alongside the visible one¹.
When hosting a signature service yourself, as we do, there are four main choices for handling the certificate: Not using a certificate, creating your own, buying a trusted certificate, and becoming and trusted service provider to issue your own trusted certificate.
## 1\. No Certificate
A lot of signing services actually dont employ actual digital signatures besides the inserted image. The only insert and image of the signatures into the document you sign. This can be done and is legally acceptable in many cases. This option isnt directly supported by Documenso without changing the code.
## 2\. Create your own
Since the cryptography behind certificates is freely available as open source you could generate your own using OpenSSL for example. Since its hardly more work than option 1 (using Documenso at least), this would be my minimum effort recommendation. Having a self-created (“self-signed”) certificate doesnt add much in terms of regulation but it guarantees the documents integrity, meaning no changes have been made after signing². What this doesnt give you, is the famous green checkmark in Adobe Acrobat. Why? Because you arent on the list of providers Adobe “trusts”.³
## 3\. Buy a “trusted” certificate.
There are Certificate Authorities (CAs) that can sell you a certificate⁴. The service they provide is, that they validate your name (personal certificates) or your organizations name (corporate certificate) before creating your certificate for you, just like you did in option 2. The difference is, that they are listed on the previously mentioned trust lists (e.g. Adobes) and thus the resulting signatures get a nice, green checkmark in Adobe Reader⁵
## 4\. Becoming a Trusted Certificate Authority (CA) yourself and create your own certificate
This option is an incredibly complex endeavour, requiring a lot of effort and skill. It can be done, as there are multiple CAs around the world. Is it worth the effort? That depends a lot on what youre trying to accomplish.
<center>.&nbsp;&nbsp;.&nbsp;&nbsp;.</center>
## What we did
Having briefly introduced the options, here is what we did: Since we aim to raise the bar on digital signature proliferation and trust, we opted to buy an “Advanced Personal Certificates for Companies/Organisations” from WiseKey. Thus, documents signed with Documensos hosted version look like this:
<figure>
<MdxNextImage
src="/blog/blog-fig-building-documenso.webp"
width="1262"
height="481"
alt="Figure 1"
/>
<figcaption className="text-center">The famous green checkmark: Signed by hosted Documenso</figcaption>
</figure>
There werent any deeper reasons we choose WiseKey, other than they offered what we needed and there wasnt any reason to look much further. While I didnt map the entire certificate market offering (yet), Im pretty sure something similar could be found elsewhere. While we opted for option 3, choosing option 2 might be perfectly reasonable considering your use case.⁶
> While this is our setup, for now, we have a bigger plan for this topic. While globally trusted SSL Certificates have been available for free, courtesy of Lets Encrypt, for a while now, there is no such thing as document signing. And there should be. Not having free and trusted infrastructure for signing is blocking a completely new generation of signing products from being created. This is why well start working on option 4 when the time is right.
Do you have questions or thoughts about this? As always, let me know in the comments, on <a href="http://twitter.com/eltimuro" target="_blank">twitter.com/eltimuro</a>
or directly: <a href="https://documen.so/timur" target="_blank">documen.so/timur</a>
Join the self-hoster community here: <a href="https://documen.so/discord" target="_blank">https://documen.so/discord</a>
Best from Hamburg
Timur
\[1\] There are different approaches to signing a document. For the sake of simplicity, here we talk about a document with X inserted signature images, that is afterward signed once the by signing service, i.e. Documenso. If each visual signature should have its own digital one (e.g. QES — eIDAS Level 3), the case is a bit more complex.
\[2\] Of course, the signing service provider technically can change and resign the document, especially in the case mentioned in \[1\]. This can be countered by requiring actual digital signatures from each signer, that are bound to their identity/ account. Creating a completely trustless system in the context however is extremely hard to do and not the most pressing business need for the industry at this point, in my opinion. Though, this would be nice.
\[3\] Adobe, like the EU, has a list of organizations they trust. The Adobe green checkmark is powered by the Adobe trust list, if you want to be trusted by EU standards here: <a href="https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation" target="_blank">https://ec.europa.eu/digital-building-blocks/DSS/webapp-demo/validation</a>, you need to be on the EU trust list. Getting on each list is possible, though the latter is much more work.
\[4\] Technically, they sign your certificate creation request (created by you), containing your info with their certificate (which is trusted), making your certificate trusted. This way, everything you sign with your certificate is seen as trusted. They created their certificate just like you, the difference is they are on the lists, mentioned in \[3\]
\[5\] Why does Adobe get to say, what is trusted? They simply happen to have the most used pdf viewer. And since everyone checks there, whom they consider trusted carries weight. If it should be like this, is a different matter.
\[6\] Self-Signed signatures, even purely visual signatures, are fully legally binding. Why you use changes mainly your confidence in the signature and the burden of proof. Also, some industries require a certain level of signatures e.g. retail loans (QES/ eIDAS Level 3 in the EU).

View File

@ -12,7 +12,7 @@ tags:
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
Last week, Lucas shared the reasoning on [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
Today, I'm pleased to share with you a preview of the next Documenso.

View File

@ -8,9 +8,16 @@ const { parsed: env } = require('dotenv').config({
/** @type {import('next').NextConfig} */
const config = {
experimental: {
serverActions: true,
},
reactStrictMode: true,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
env,
modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
},
},
};
module.exports = withContentlayer(config);

View File

@ -8,8 +8,11 @@ import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentStatus,
FieldType,
ReadStatus,
@ -85,16 +88,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const now = new Date();
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const { id: documentDataId } = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
});
const document = await prisma.document.create({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
created: now,
documentDataId,
},
include: {
documentData: true,
},
});
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipient = await prisma.recipient.create({
data: {
name: user.name ?? '',
@ -121,17 +142,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
let pdfData = await getFile(documentData).then((data) =>
Buffer.from(data).toString('base64'),
);
if (signatureDataUrl) {
document.document = await insertImageInPDF(
document.document,
pdfData = await insertImageInPDF(
pdfData,
signatureDataUrl,
Number(field.positionX),
Number(field.positionY),
field.page,
);
} else {
document.document = await insertTextInPDF(
document.document,
pdfData = await insertTextInPDF(
pdfData,
signatureText ?? '',
Number(field.positionX),
Number(field.positionY),
@ -139,6 +164,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
);
}
const { data: newData } = await updateFile({
type: documentData.type,
oldData: documentData.initialData,
newData: Buffer.from(pdfData, 'base64').toString('binary'),
});
await Promise.all([
prisma.signature.create({
data: {
@ -148,12 +179,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
typedSignature: signatureDataUrl ? '' : signatureText,
},
}),
prisma.document.update({
prisma.documentData.update({
where: {
id: document.id,
id: documentData.id,
},
data: {
document: document.document,
data: newData,
},
}),
]);

View File

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const { version } = require('./package.json');
const { parsed: env } = require('dotenv').config({
path: path.join(__dirname, '../../.env.local'),
@ -9,6 +10,7 @@ const { parsed: env } = require('dotenv').config({
const config = {
experimental: {
serverActions: true,
serverActionsBodySizeLimit: '50mb',
},
reactStrictMode: true,
transpilePackages: [
@ -18,7 +20,9 @@ const config = {
'@documenso/ui',
'@documenso/email',
],
env,
env: {
APP_VERSION: version,
},
modularizeImports: {
'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',

View File

@ -24,7 +24,6 @@
"lucide-react": "^0.214.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"next": "13.4.12",
"next-auth": "4.22.3",
"next-plausible": "^3.10.1",

View File

@ -1,34 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { TCreateDocumentRequestSchema, ZCreateDocumentResponseSchema } from './types';
export const useCreateDocument = () => {
return useMutation(async ({ file }: TCreateDocumentRequestSchema) => {
const formData = new FormData();
formData.set('file', file);
const response = await fetch('/api/document/create', {
method: 'POST',
body: formData,
});
const body = await response.json();
if (response.status !== 200) {
throw new Error('Failed to create document');
}
const safeBody = ZCreateDocumentResponseSchema.safeParse(body);
if (!safeBody.success) {
throw new Error('Failed to create document');
}
if ('error' in safeBody.data) {
throw new Error(safeBody.data.error);
}
return safeBody.data;
});
};

View File

@ -1,19 +0,0 @@
import { z } from 'zod';
export const ZCreateDocumentRequestSchema = z.object({
file: z.instanceof(File),
});
export type TCreateDocumentRequestSchema = z.infer<typeof ZCreateDocumentRequestSchema>;
export const ZCreateDocumentResponseSchema = z
.object({
id: z.number(),
})
.or(
z.object({
error: z.string(),
}),
);
export type TCreateDocumentResponseSchema = z.infer<typeof ZCreateDocumentResponseSchema>;

View File

@ -0,0 +1,30 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { AdminNav } from './nav';
export type AdminSectionLayoutProps = {
children: React.ReactNode;
};
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
const user = await getRequiredServerComponentSession();
if (!isAdmin(user)) {
redirect('/documents');
}
return (
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
<div className="grid grid-cols-12 gap-x-8 md:mt-8">
<AdminNav className="col-span-12 md:col-span-3 md:flex" />
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">{children}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
'use client';
import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { BarChart3, User2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type AdminNavProps = HTMLAttributes<HTMLDivElement>;
export const AdminNav = ({ className, ...props }: AdminNavProps) => {
const pathname = usePathname();
return (
<div className={cn('flex gap-x-2.5 gap-y-2 md:flex-col', className)} {...props}>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/stats') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/stats">
<BarChart3 className="mr-2 h-5 w-5" />
Stats
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/users') && 'bg-secondary',
)}
disabled
>
<User2 className="mr-2 h-5 w-5" />
Users (Coming Soon)
</Button>
</div>
);
};

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Admin() {
redirect('/admin/stats');
}

View File

@ -0,0 +1,75 @@
import {
File,
FileCheck,
FileClock,
FileEdit,
Mail,
MailOpen,
PenTool,
User as UserIcon,
UserPlus2,
UserSquare2,
} from 'lucide-react';
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
import {
getUsersCount,
getUsersWithSubscriptionsCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
export default async function AdminStatsPage() {
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
getUsersCount(),
getUsersWithSubscriptionsCount(),
getDocumentStats(),
getRecipientsStats(),
]);
return (
<div>
<h2 className="text-4xl font-semibold">Instance Stats</h2>
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-4">
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric
icon={UserPlus2}
title="Active Subscriptions"
value={usersWithSubscriptionsCount}
/>
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
</div>
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
<div>
<h3 className="text-3xl font-semibold">Document metrics</h3>
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
</div>
</div>
<div>
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
<CardMetric
icon={UserSquare2}
title="Total Recipients"
value={recipientStats.TOTAL_RECIPIENTS}
/>
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
</div>
</div>
</div>
</div>
);
}

View File

@ -1,124 +0,0 @@
import Link from 'next/link';
import { Clock, File, FileCheck } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
import { UploadDocument } from './upload-document';
const CARD_DATA = [
{
icon: FileCheck,
title: 'Completed',
status: InternalDocumentStatus.COMPLETED,
},
{
icon: File,
title: 'Drafts',
status: InternalDocumentStatus.DRAFT,
},
{
icon: Clock,
title: 'Pending',
status: InternalDocumentStatus.PENDING,
},
];
export default async function DashboardPage() {
const user = await getRequiredServerComponentSession();
const [stats, results] = await Promise.all([
getStats({
user,
}),
findDocuments({
userId: user.id,
perPage: 10,
}),
]);
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">Dashboard</h1>
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
{CARD_DATA.map((card) => (
<Link key={card.status} href={`/documents?status=${card.status}`}>
<CardMetric icon={card.icon} title={card.title} value={stats[card.status]} />
</Link>
))}
</div>
<div className="mt-12">
<UploadDocument />
<h2 className="mt-8 text-2xl font-semibold">Recent Documents</h2>
<div className="border-border mt-8 overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Title</TableHead>
<TableHead>Reciepient</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.data.map((document) => {
return (
<TableRow key={document.id}>
<TableCell className="font-medium">{document.id}</TableCell>
<TableCell>
<Link
href={`/documents/${document.id}`}
className="focus-visible:ring-ring ring-offset-background rounded-md font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
>
{document.title}
</Link>
</TableCell>
<TableCell>
<StackAvatarsWithTooltip recipients={document.Recipient} />
</TableCell>
<TableCell>
<DocumentStatus status={document.status} />
</TableCell>
<TableCell className="text-right">
<LocaleDate date={document.created} />
</TableCell>
</TableRow>
);
})}
{results.data.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@ -4,7 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
import { Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
@ -28,9 +29,10 @@ import { completeDocument } from '~/components/forms/edit-document/add-subject.a
export type EditDocumentFormProps = {
className?: string;
user: User;
document: Document;
document: DocumentWithData;
recipients: Recipient[];
fields: Field[];
dataUrl: string;
};
type EditDocumentStep = 'signers' | 'fields' | 'subject';
@ -41,14 +43,13 @@ export const EditDocumentForm = ({
recipients,
fields,
user: _user,
dataUrl,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditDocumentStep>('signers');
const documentUrl = `data:application/pdf;base64,${document.document}`;
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
signers: {
title: 'Add Signers',
@ -136,7 +137,7 @@ export const EditDocumentForm = ({
duration: 5000,
});
router.push('/dashboard');
router.push('/documents');
} catch (err) {
console.error(err);
@ -151,11 +152,11 @@ export const EditDocumentForm = ({
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={documentUrl} />
<LazyPDFViewer document={dataUrl} />
</CardContent>
</Card>

View File

@ -1,20 +0,0 @@
'use client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
export type LoadablePDFCard = PDFViewerProps & {
className?: string;
pdfClassName?: string;
};
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
return (
<Card className={className} gradient {...props}>
<CardContent className="p-2">
<LazyPDFViewer className={pdfClassName} {...props} />
</CardContent>
</Card>
);
};

View File

@ -7,6 +7,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -36,10 +37,16 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
userId: session.id,
}).catch(() => null);
if (!document) {
if (!document || !document.documentData) {
redirect('/documents');
}
const { documentData } = document;
const documentDataUrl = await getFile(documentData)
.then((buffer) => Buffer.from(buffer).toString('base64'))
.then((data) => `data:application/pdf;base64,${data}`);
const [recipients, fields] = await Promise.all([
await getRecipientsForDocument({
documentId,
@ -86,12 +93,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
user={session}
recipients={recipients}
fields={fields}
dataUrl={documentDataUrl}
/>
)}
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
<LazyPDFViewer document={documentDataUrl} />
</div>
)}
</div>

View File

@ -15,7 +15,10 @@ import {
} from 'lucide-react';
import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/client';
import {
DropdownMenu,
DropdownMenuContent,
@ -47,17 +50,26 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onDownloadClick = () => {
let decodedDocument = row.document;
const onDownloadClick = async () => {
let document: DocumentWithData | null = null;
try {
decodedDocument = atob(decodedDocument);
} catch (err) {
// We're just going to ignore this error and try to download the document
console.error(err);
if (!recipient) {
document = await trpc.document.getDocumentById.query({
id: row.id,
});
} else {
document = await trpc.document.getDocumentByToken.query({
token: recipient.token,
});
}
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
const documentData = document?.documentData;
if (!documentData) {
return;
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], {
type: 'application/pdf',

View File

@ -0,0 +1,56 @@
'use client';
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { Document, Recipient, User } from '@documenso/prisma/client';
export type DataTableTitleProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
};
};
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const { data: session } = useSession();
if (!session) {
return null;
}
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
const isRecipient = !!recipient;
return match({
isOwner,
isRecipient,
})
.with({ isOwner: true }, () => (
<Link
href={`/documents/${row.id}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.with({ isRecipient: true }, () => (
<Link
href={`/sign/${recipient?.token}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
))
.otherwise(() => (
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{row.title}
</span>
));
};

View File

@ -2,9 +2,8 @@
import { useTransition } from 'react';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set';
@ -18,6 +17,7 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { DataTableActionButton } from './data-table-action-button';
import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
export type DocumentsDataTableProps = {
results: FindResultSet<
@ -29,6 +29,7 @@ export type DocumentsDataTableProps = {
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
@ -42,25 +43,22 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
});
};
if (!session) {
return null;
}
return (
<div className="relative">
<DataTable
columns={[
{
header: 'ID',
accessorKey: 'id',
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
cell: ({ row }) => (
<Link
href={`/documents/${row.original.id}`}
title={row.original.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.original.title}
</Link>
),
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
header: 'Recipient',
@ -74,11 +72,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
},
{
header: 'Created',
accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
},
{
header: 'Actions',
cell: ({ row }) => (
@ -95,7 +88,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination table={table} />}
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (

View File

@ -11,8 +11,8 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { UploadDocument } from '../dashboard/upload-document';
import { DocumentsDataTable } from './data-table';
import { UploadDocument } from './upload-document';
export type DocumentsPageProps = {
searchParams?: {
@ -39,7 +39,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
userId: user.id,
status,
orderBy: {
column: 'created',
column: 'createdAt',
direction: 'desc',
},
page,
@ -81,6 +81,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>

View File

@ -1,29 +1,45 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCreateDocument } from '~/api/document/create/fetcher';
export type UploadDocumentProps = {
className?: string;
};
export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { toast } = useToast();
const router = useRouter();
const { isLoading, mutateAsync: createDocument } = useCreateDocument();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
const { type, data } = await putFile(file);
const { id: documentDataId } = await createDocumentData({
type,
data,
});
const { id } = await createDocument({
file: file,
title: file.name,
documentDataId,
});
toast({
@ -41,6 +57,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
description: 'An error occurred while uploading your document.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};

View File

@ -21,16 +21,18 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile');
}
let subscription = await getSubscriptionByUserId({ userId: user.id });
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
if (sub) {
return sub;
}
// If we don't have a customer record, create one as well as an empty subscription.
if (!subscription?.customerId) {
subscription = await createCustomer({ user });
}
// If we don't have a customer record, create one as well as an empty subscription.
return createCustomer({ user });
});
let billingPortalUrl = '';
if (subscription?.customerId) {
if (subscription.customerId) {
billingPortalUrl = await getPortalSession({
customerId: subscription.customerId,
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,

View File

@ -1,55 +1,64 @@
'use client';
import { HTMLAttributes } from 'react';
import { HTMLAttributes, useState } from 'react';
import { Download } from 'lucide-react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentData } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
fileName?: string;
document?: string;
documentData?: DocumentData;
};
export const DownloadButton = ({
className,
fileName,
document,
documentData,
disabled,
...props
}: DownloadButtonProps) => {
/**
* Convert the document from base64 to a blob and download it.
*/
const onDownloadClick = () => {
if (!document) {
return;
}
const { toast } = useToast();
let decodedDocument = document;
const [isLoading, setIsLoading] = useState(false);
const onDownloadClick = async () => {
try {
decodedDocument = atob(document);
setIsLoading(true);
if (!documentData) {
return;
}
const bytes = await getFile(documentData);
const blob = new Blob([bytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (err) {
// We're just going to ignore this error and try to download the document
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
return (
@ -57,8 +66,9 @@ export const DownloadButton = ({
type="button"
variant="outline"
className={className}
disabled={disabled || !document}
disabled={disabled || !documentData}
onClick={onDownloadClick}
loading={isLoading}
{...props}
>
<Download className="mr-2 h-5 w-5" />

View File

@ -30,15 +30,21 @@ export default async function CompletedSigningPage({
token,
}).catch(() => null);
if (!document) {
if (!document || !document.documentData) {
return notFound();
}
const { documentData } = document;
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
if (!recipient) {
return notFound();
}
const recipientName =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
@ -91,7 +97,7 @@ export default async function CompletedSigningPage({
<DownloadButton
className="flex-1"
fileName={document.title}
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined}
documentData={documentData}
disabled={document.status !== DocumentStatus.COMPLETED}
/>
</div>

View File

@ -8,6 +8,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@ -36,17 +37,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
token,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token }),
]);
if (!document) {
if (!document || !document.documentData || !recipient) {
return notFound();
}
const user = await getServerComponentSession();
const { documentData } = document;
const documentUrl = `data:application/pdf;base64,${document.document}`;
const documentDataUrl = await getFile(documentData)
.then((buffer) => Buffer.from(buffer).toString('base64'))
.then((data) => `data:application/pdf;base64,${data}`);
const user = await getServerComponentSession();
return (
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
@ -67,7 +72,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={documentUrl} />
<LazyPDFViewer document={documentDataUrl} />
</CardContent>
</Card>

View File

@ -2,6 +2,8 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster';
@ -45,6 +47,8 @@ export const metadata = {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags();
const locale = getLocale();
return (
<html
lang="en"
@ -63,16 +67,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</Suspense>
<body>
<FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>
<LocaleProvider locale={locale}>
<FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>
</LocaleProvider>
</body>
</html>
);

View File

@ -1,6 +1,6 @@
'use client';
import { HTMLAttributes } from 'react';
import { HTMLAttributes, useEffect, useState } from 'react';
import Link from 'next/link';
@ -17,10 +17,23 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
};
export const Header = ({ className, user, ...props }: HeaderProps) => {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b backdrop-blur',
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
className,
)}
{...props}

View File

@ -11,10 +11,12 @@ import {
Monitor,
Moon,
Sun,
UserCog,
} from 'lucide-react';
import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
@ -36,8 +38,8 @@ export type ProfileDropdownProps = {
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const { theme, setTheme } = useTheme();
const { getFlag } = useFeatureFlags();
const isUserAdmin = isAdmin(user);
const isBillingEnabled = getFlag('app_billing');
@ -58,6 +60,19 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel>Account</DropdownMenuLabel>
{isUserAdmin && (
<>
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<UserCog className="mr-2 h-4 w-4" />
Admin
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="cursor-pointer">
<LucideUser className="mr-2 h-4 w-4" />

View File

@ -18,10 +18,10 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
)}
>
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex items-start">
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}
<div className="flex items-center">
{Icon && <Icon className="text-muted-foreground mr-2 h-4 w-4" />}
<h3 className="flex items-end text-sm font-medium text-slate-500">{title}</h3>
<h3 className="text-primary-forground flex items-end text-sm font-medium">{title}</h3>
</div>
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">

View File

@ -2,16 +2,31 @@
import { HTMLAttributes, useEffect, useState } from 'react';
import { DateTime, DateTimeFormatOptions } from 'luxon';
import { useLocale } from '@documenso/lib/client-only/providers/locale';
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
date: string | number | Date;
format?: DateTimeFormatOptions;
};
export const LocaleDate = ({ className, date, ...props }: LocaleDateProps) => {
const [localeDate, setLocaleDate] = useState(() => new Date(date).toISOString());
/**
* Formats the date based on the user locale.
*
* Will use the estimated locale from the user headers on SSR, then will use
* the client browser locale once mounted.
*/
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
const { locale } = useLocale();
const [localeDate, setLocaleDate] = useState(() =>
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
);
useEffect(() => {
setLocaleDate(new Date(date).toLocaleString());
}, [date]);
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
}, [date, format]);
return (
<span className={className} {...props}>

View File

@ -18,13 +18,15 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ErrorMessages = {
const ERROR_MESSAGES = {
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
[ErrorCode.USER_MISSING_PASSWORD]:
'This account appears to be using a social login method, please sign in using that method',
};
const LOGIN_REDIRECT_PATH = '/documents';
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),
password: z.string().min(6).max(72),
@ -37,9 +39,10 @@ export type SignInFormProps = {
};
export const SignInForm = ({ className }: SignInFormProps) => {
const { toast } = useToast();
const searchParams = useSearchParams();
const { toast } = useToast();
const {
register,
handleSubmit,
@ -61,7 +64,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
timeout = setTimeout(() => {
toast({
variant: 'destructive',
description: ErrorMessages[errorCode] ?? 'An unknown error occurred',
description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
});
}, 0);
}
@ -78,12 +81,10 @@ export const SignInForm = ({ className }: SignInFormProps) => {
await signIn('credentials', {
email,
password,
callbackUrl: '/documents',
callbackUrl: LOGIN_REDIRECT_PATH,
}).catch((err) => {
console.error(err);
});
// throw new Error('Not implemented');
} catch (err) {
toast({
title: 'An unknown error occurred',
@ -95,8 +96,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
const onSignInWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: '/dashboard' });
// throw new Error('Not implemented');
await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',

View File

@ -1,88 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import formidable, { type File } from 'formidable';
import { readFileSync } from 'fs';
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import {
TCreateDocumentRequestSchema,
TCreateDocumentResponseSchema,
} from '~/api/document/create/types';
export const config = {
api: {
bodyParser: false,
},
};
export type TFormidableCreateDocumentRequestSchema = {
file: File;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<TCreateDocumentResponseSchema>,
) {
const user = await getServerSession({ req, res });
if (!user) {
return res.status(401).json({
error: 'Unauthorized',
});
}
try {
const form = formidable();
const { file } = await new Promise<TFormidableCreateDocumentRequestSchema>(
(resolve, reject) => {
form.parse(req, (err, fields, files) => {
if (err) {
reject(err);
}
// We had intended to do this with Zod but we can only validate it
// as a persistent file which does not include the properties that we
// need.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
resolve({ ...fields, ...files } as any);
});
},
);
const fileBuffer = readFileSync(file.filepath);
const document = await prisma.document.create({
data: {
title: file.originalFilename ?? file.newFilename,
status: DocumentStatus.DRAFT,
userId: user.id,
document: fileBuffer.toString('base64'),
created: new Date(),
},
});
return res.status(200).json({
id: document.id,
});
} catch (err) {
console.error(err);
return res.status(500).json({
error: 'Internal server error',
});
}
}
/**
* This is a hack to ensure that the types are correct.
*/
type FormidableSatisfiesCreateDocument =
keyof TCreateDocumentRequestSchema extends keyof TFormidableCreateDocumentRequestSchema
? true
: never;
true satisfies FormidableSatisfiesCreateDocument;

View File

@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid';
import { JWT, getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { nanoid } from '@documenso/lib/universal/id';
import PostHogServerClient from '~/helpers/get-post-hog-server-client';

View File

@ -10,6 +10,7 @@ import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentStatus,
FieldType,
ReadStatus,
@ -85,16 +86,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const now = new Date();
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const { id: documentDataId } = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
});
const document = await prisma.document.create({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
created: now,
documentDataId,
},
include: {
documentData: true,
},
});
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipient = await prisma.recipient.create({
data: {
name: user.name ?? '',
@ -122,16 +141,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
if (signatureDataUrl) {
document.document = await insertImageInPDF(
document.document,
documentData.data = await insertImageInPDF(
documentData.data,
signatureDataUrl,
field.positionX.toNumber(),
field.positionY.toNumber(),
field.page,
);
} else {
document.document = await insertTextInPDF(
document.document,
documentData.data = await insertTextInPDF(
documentData.data,
signatureText ?? '',
field.positionX.toNumber(),
field.positionY.toNumber(),
@ -153,7 +172,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: document.id,
},
data: {
document: document.document,
documentData: {
update: {
data: documentData.data,
},
},
},
}),
]);

1808
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,13 +16,12 @@
"worker:test": "tsup worker/index.ts --format esm"
},
"dependencies": {
"@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*",
"@documenso/ui": "*",
"@react-email/components": "^0.0.7",
"nodemailer": "^6.9.3"
},
"devDependencies": {
"@documenso/tsconfig": "*",
"@documenso/tailwind-config": "*",
"@types/nodemailer": "^6.4.8",
"tsup": "^7.1.0"
}

View File

@ -4,8 +4,5 @@ const path = require('path');
module.exports = {
...baseConfig,
content: [
`templates/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
],
content: [`templates/**/*.{ts,tsx}`],
};

View File

@ -0,0 +1,37 @@
'use client';
import { createContext, useContext } from 'react';
export type LocaleContextValue = {
locale: string;
};
export const LocaleContext = createContext<LocaleContextValue | null>(null);
export const useLocale = () => {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('useLocale must be used within a LocaleProvider');
}
return context;
};
export function LocaleProvider({
children,
locale,
}: {
children: React.ReactNode;
locale: string;
}) {
return (
<LocaleContext.Provider
value={{
locale: locale,
}}
>
{children}
</LocaleContext.Provider>
);
}

View File

@ -0,0 +1,5 @@
export const ONE_SECOND = 1000;
export const ONE_MINUTE = ONE_SECOND * 60;
export const ONE_HOUR = ONE_MINUTE * 60;
export const ONE_DAY = ONE_HOUR * 24;
export const ONE_WEEK = ONE_DAY * 7;

View File

@ -0,0 +1,5 @@
import { Role, User } from '@documenso/prisma/client';
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
export { isAdmin };

View File

@ -12,10 +12,15 @@
],
"scripts": {},
"dependencies": {
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4-crt": "^3.410.0",
"@documenso/email": "*",
"@documenso/prisma": "*",
"@next-auth/prisma-adapter": "1.0.7",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0",
"luxon": "^3.4.0",

View File

@ -0,0 +1,26 @@
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export const getDocumentStats = async () => {
const counts = await prisma.document.groupBy({
by: ['status'],
_count: {
_all: true,
},
});
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
counts.forEach((stat) => {
stats[stat.status] = stat._count._all;
stats.ALL += stat._count._all;
});
return stats;
};

View File

@ -0,0 +1,29 @@
import { prisma } from '@documenso/prisma';
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientsStats = async () => {
const results = await prisma.recipient.groupBy({
by: ['readStatus', 'signingStatus', 'sendStatus'],
_count: true,
});
const stats = {
TOTAL_RECIPIENTS: 0,
[ReadStatus.OPENED]: 0,
[ReadStatus.NOT_OPENED]: 0,
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};
results.forEach((result) => {
const { readStatus, signingStatus, sendStatus, _count } = result;
stats[readStatus] += _count;
stats[signingStatus] += _count;
stats[sendStatus] += _count;
stats.TOTAL_RECIPIENTS += _count;
});
return stats;
};

View File

@ -0,0 +1,18 @@
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export const getUsersCount = async () => {
return await prisma.user.count();
};
export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({
where: {
Subscription: {
some: {
status: SubscriptionStatus.ACTIVE,
},
},
},
});
};

View File

@ -0,0 +1,19 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentDataType } from '@documenso/prisma/client';
export type CreateDocumentDataOptions = {
type: DocumentDataType;
data: string;
};
export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
return await prisma.documentData.create({
data: {
type,
data,
initialData: data,
},
});
};

View File

@ -0,0 +1,19 @@
'use server';
import { prisma } from '@documenso/prisma';
export type CreateDocumentOptions = {
title: string;
userId: number;
documentDataId: string;
};
export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => {
return await prisma.document.create({
data: {
title,
documentDataId,
userId,
},
});
};

View File

@ -32,7 +32,7 @@ export const findDocuments = async ({
},
});
const orderByColumn = orderBy?.column ?? 'created';
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const termFilters = !term

View File

@ -11,5 +11,8 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
id,
userId,
},
include: {
documentData: true,
},
});
};

View File

@ -17,6 +17,7 @@ export const getDocumentAndSenderByToken = async ({
},
include: {
User: true,
documentData: true,
},
});

View File

@ -1,10 +1,13 @@
'use server';
import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
export type SealDocumentOptions = {
@ -18,8 +21,17 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
where: {
id: documentId,
},
include: {
documentData: true,
},
});
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
@ -48,7 +60,7 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
}
// !: Need to write the fields onto the document as a hard copy
const { document: pdfData } = document;
const pdfData = await getFile(documentData);
const doc = await PDFDocument.load(pdfData);
@ -58,13 +70,20 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
const pdfBytes = await doc.save();
await prisma.document.update({
const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({
name: `${name}_signed${ext}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(Buffer.from(pdfBytes)),
});
await prisma.documentData.update({
where: {
id: document.id,
status: DocumentStatus.COMPLETED,
id: documentData.id,
},
data: {
document: Buffer.from(pdfBytes).toString('base64'),
data: newData,
},
});
};

View File

@ -1,8 +1,8 @@
import { nanoid } from 'nanoid';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
export interface SetRecipientsForDocumentOptions {
userId: number;
documentId: number;

View File

@ -1,5 +1,8 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"compilerOptions": {
"types": ["@documenso/tsconfig/process-env.d.ts"]
},
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -0,0 +1,5 @@
import { customAlphabet } from 'nanoid';
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
export { nanoid } from 'nanoid';

View File

@ -0,0 +1,22 @@
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { deleteS3File } from './server-actions';
export type DeleteFileOptions = {
type: DocumentDataType;
data: string;
};
export const deleteFile = async ({ type, data }: DeleteFileOptions) => {
return await match(type)
.with(DocumentDataType.S3_PATH, async () => deleteFileFromS3(data))
.otherwise(() => {
return;
});
};
const deleteFileFromS3 = async (key: string) => {
await deleteS3File(key);
};

View File

@ -0,0 +1,51 @@
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { getPresignGetUrl } from './server-actions';
export type GetFileOptions = {
type: DocumentDataType;
data: string;
};
export const getFile = async ({ type, data }: GetFileOptions) => {
return await match(type)
.with(DocumentDataType.BYTES, () => getFileFromBytes(data))
.with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data))
.with(DocumentDataType.S3_PATH, async () => getFileFromS3(data))
.exhaustive();
};
const getFileFromBytes = (data: string) => {
const encoder = new TextEncoder();
const binaryData = encoder.encode(data);
return binaryData;
};
const getFileFromBytes64 = (data: string) => {
const binaryData = base64.decode(data);
return binaryData;
};
const getFileFromS3 = async (key: string) => {
const { url } = await getPresignGetUrl(key);
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to get file "${key}", failed with status code ${response.status}`);
}
const buffer = await response.arrayBuffer();
const binaryData = new Uint8Array(buffer);
return binaryData;
};

View File

@ -0,0 +1,59 @@
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { getPresignPostUrl } from './server-actions';
type File = {
name: string;
type: string;
arrayBuffer: () => Promise<ArrayBuffer>;
};
export const putFile = async (file: File) => {
const { type, data } = await match(process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));
return await createDocumentData({ type, data });
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInS3 = async (file: File) => {
const { url, key } = await getPresignPostUrl(file.name, file.type);
const body = await file.arrayBuffer();
const reponse = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
},
body,
});
if (!reponse.ok) {
throw new Error(
`Failed to upload file "${file.name}", failed with status code ${reponse.status}`,
);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};

View File

@ -0,0 +1,104 @@
'use server';
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import slugify from '@sindresorhus/slugify';
import path from 'node:path';
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
import { getServerComponentSession } from '../../next-auth/get-server-session';
import { alphaid } from '../id';
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
const client = getS3Client();
const user = await getServerComponentSession();
// Get the basename and extension for the file
const { name, ext } = path.parse(fileName);
let key = `${alphaid(12)}/${slugify(name)}${ext}`;
if (user) {
key = `${user.id}/${key}`;
}
const putObjectCommand = new PutObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
};
export const getAbsolutePresignPostUrl = async (key: string) => {
const client = getS3Client();
const putObjectCommand = new PutObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
});
const url = await getSignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
};
export const getPresignGetUrl = async (key: string) => {
const client = getS3Client();
const getObjectCommand = new GetObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
});
const url = await getSignedUrl(client, getObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
};
export const deleteS3File = async (key: string) => {
const client = getS3Client();
await client.send(
new DeleteObjectCommand({
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
Key: key,
}),
);
};
const getS3Client = () => {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
throw new Error('Invalid upload transport');
}
const hasCredentials =
process.env.NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID &&
process.env.NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY;
return new S3Client({
endpoint: process.env.NEXT_PRIVATE_UPLOAD_ENDPOINT || undefined,
region: process.env.NEXT_PRIVATE_UPLOAD_REGION || 'us-east-1',
credentials: hasCredentials
? {
accessKeyId: String(process.env.NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID),
secretAccessKey: String(process.env.NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY),
}
: undefined,
});
};

View File

@ -0,0 +1,58 @@
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
import { getAbsolutePresignPostUrl } from './server-actions';
export type UpdateFileOptions = {
type: DocumentDataType;
oldData: string;
newData: string;
};
export const updateFile = async ({ type, oldData, newData }: UpdateFileOptions) => {
return await match(type)
.with(DocumentDataType.BYTES, () => updateFileWithBytes(newData))
.with(DocumentDataType.BYTES_64, () => updateFileWithBytes64(newData))
.with(DocumentDataType.S3_PATH, async () => updateFileWithS3(oldData, newData))
.exhaustive();
};
const updateFileWithBytes = (data: string) => {
return {
type: DocumentDataType.BYTES,
data,
};
};
const updateFileWithBytes64 = (data: string) => {
const encoder = new TextEncoder();
const binaryData = encoder.encode(data);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const updateFileWithS3 = async (key: string, data: string) => {
const { url } = await getAbsolutePresignPostUrl(key);
const response = await fetch(url, {
method: 'PUT',
body: data,
});
if (!response.ok) {
throw new Error(`Failed to update file "${key}", failed with status code ${response.status}`);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};

View File

@ -0,0 +1,19 @@
-- CreateEnum
CREATE TYPE "DocumentDataType" AS ENUM ('S3_PATH', 'BYTES', 'BYTES_64');
-- CreateTable
CREATE TABLE "DocumentData" (
"id" TEXT NOT NULL,
"type" "DocumentDataType" NOT NULL,
"data" TEXT NOT NULL,
"initialData" TEXT NOT NULL,
"documentId" INTEGER NOT NULL,
CONSTRAINT "DocumentData_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DocumentData_documentId_key" ON "DocumentData"("documentId");
-- AddForeignKey
ALTER TABLE "DocumentData" ADD CONSTRAINT "DocumentData_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,14 @@
INSERT INTO
"DocumentData" ("id", "type", "data", "initialData", "documentId") (
SELECT
CAST(gen_random_uuid() AS TEXT),
'BYTES_64',
d."document",
d."document",
d."id"
FROM
"Document" d
WHERE
d."id" IS NOT NULL
AND d."document" IS NOT NULL
);

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[];

View File

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "createdAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "updatedAt" TIMESTAMP(3);
-- DefaultValues
UPDATE "Document"
SET
"createdAt" = COALESCE("created"::TIMESTAMP, NOW()),
"updatedAt" = COALESCE("created"::TIMESTAMP, NOW());
-- AlterColumn
ALTER TABLE "Document" ALTER COLUMN "createdAt" SET DEFAULT NOW();
ALTER TABLE "Document" ALTER COLUMN "createdAt" SET NOT NULL;
-- AlterColumn
ALTER TABLE "Document" ALTER COLUMN "updatedAt" SET DEFAULT NOW();
ALTER TABLE "Document" ALTER COLUMN "updatedAt" SET NOT NULL;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `document` on the `Document` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "document";

View File

@ -0,0 +1,23 @@
-- DropForeignKey
ALTER TABLE "DocumentData" DROP CONSTRAINT "DocumentData_documentId_fkey";
-- DropIndex
DROP INDEX "DocumentData_documentId_key";
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "documentDataId" TEXT;
-- Reverse relation foreign key ids
UPDATE "Document" SET "documentDataId" = "DocumentData"."id" FROM "DocumentData" WHERE "Document"."id" = "DocumentData"."documentId";
-- AlterColumn
ALTER TABLE "Document" ALTER COLUMN "documentDataId" SET NOT NULL;
-- AlterTable
ALTER TABLE "DocumentData" DROP COLUMN "documentId";
-- CreateIndex
CREATE UNIQUE INDEX "Document_documentDataId_key" ON "Document"("documentDataId");
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_documentDataId_fkey" FOREIGN KEY ("documentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `created` on the `Document` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "created";

View File

@ -13,6 +13,11 @@ enum IdentityProvider {
GOOGLE
}
enum Role {
ADMIN
USER
}
model User {
id Int @id @default(autoincrement())
name String?
@ -21,6 +26,7 @@ model User {
password String?
source String?
signature String?
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[]
sessions Session[]
@ -85,15 +91,33 @@ enum DocumentStatus {
}
model Document {
id Int @id @default(autoincrement())
created DateTime @default(now())
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
status DocumentStatus @default(DRAFT)
document String
Recipient Recipient[]
Field Field[]
id Int @id @default(autoincrement())
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
status DocumentStatus @default(DRAFT)
Recipient Recipient[]
Field Field[]
documentDataId String
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([documentDataId])
}
enum DocumentDataType {
S3_PATH
BYTES
BYTES_64
}
model DocumentData {
id String @id @default(cuid())
type DocumentDataType
data String
initialData String
Document Document?
}
enum ReadStatus {

View File

@ -0,0 +1,5 @@
import { Document, DocumentData } from '@documenso/prisma/client';
export type DocumentWithData = Document & {
documentData?: DocumentData | null;
};

View File

@ -1,17 +1,81 @@
import { TRPCError } from '@trpc/server';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { authenticatedProcedure, router } from '../trpc';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
ZCreateDocumentMutationSchema,
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
ZSendDocumentMutationSchema,
ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema,
} from './schema';
export const documentRouter = router({
getDocumentById: authenticatedProcedure
.input(ZGetDocumentByIdQuerySchema)
.query(async ({ input, ctx }) => {
try {
const { id } = input;
return await getDocumentById({
id,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this document. Please try again later.',
});
}
}),
getDocumentByToken: procedure.input(ZGetDocumentByTokenQuerySchema).query(async ({ input }) => {
try {
const { token } = input;
return await getDocumentAndSenderByToken({
token,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this document. Please try again later.',
});
}
}),
createDocument: authenticatedProcedure
.input(ZCreateDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { title, documentDataId } = input;
return await createDocument({
userId: ctx.user.id,
title,
documentDataId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',
});
}
}),
setRecipientsForDocument: authenticatedProcedure
.input(ZSetRecipientsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -2,6 +2,25 @@ import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1),
});
export type TGetDocumentByIdQuerySchema = z.infer<typeof ZGetDocumentByIdQuerySchema>;
export const ZGetDocumentByTokenQuerySchema = z.object({
token: z.string().min(1),
});
export type TGetDocumentByTokenQuerySchema = z.infer<typeof ZGetDocumentByTokenQuerySchema>;
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
documentDataId: z.string().min(1),
});
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
export const ZSetRecipientsForDocumentMutationSchema = z.object({
documentId: z.number(),
recipients: z.array(

View File

@ -13,6 +13,13 @@ declare namespace NodeJS {
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_UPLOAD_TRANSPORT?: 'database' | 's3';
NEXT_PRIVATE_UPLOAD_ENDPOINT?: string;
NEXT_PRIVATE_UPLOAD_REGION?: string;
NEXT_PRIVATE_UPLOAD_BUCKET?: string;
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID?: string;
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY?: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api';
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;

View File

@ -15,6 +15,7 @@
"lint": "eslint \"**/*.ts*\""
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
@ -22,6 +23,7 @@
"typescript": "^5.1.6"
},
"dependencies": {
"@documenso/lib": "*",
"@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2",
@ -51,7 +53,6 @@
"class-variance-authority": "^0.6.0",
"clsx": "^1.2.1",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
"next": "13.4.12",
@ -61,4 +62,4 @@
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5"
}
}
}

View File

@ -1,19 +1,46 @@
import { Table } from '@tanstack/react-table';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { match } from 'ts-pattern';
import { Button } from './button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
interface DataTablePaginationProps<TData> {
table: Table<TData>;
/**
* The type of information to show on the left hand side of the pagination.
*
* Defaults to 'VisibleCount'.
*/
additionalInformation?: 'SelectedCount' | 'VisibleCount' | 'None';
}
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
export function DataTablePagination<TData>({
table,
additionalInformation = 'VisibleCount',
}: DataTablePaginationProps<TData>) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-4 px-2">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
{match(additionalInformation)
.with('SelectedCount', () => (
<span>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</span>
))
.with('VisibleCount', () => {
const visibleRows = table.getFilteredRowModel().rows.length;
return (
<span>
Showing {visibleRows} result{visibleRows > 1 && 's'}.
</span>
);
})
.with('None', () => null)
.exhaustive()}
</div>
<div className="flex items-center gap-x-2">

View File

@ -5,12 +5,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { nanoid } from '@documenso/lib/universal/id';
import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';

View File

@ -5,9 +5,9 @@ import React, { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { nanoid } from '@documenso/lib/universal/id';
import { Field, Recipient, SendStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';

View File

@ -2,13 +2,8 @@
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
".next/**",
"!.next/cache/**"
]
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
},
"lint": {},
"dev": {
@ -16,10 +11,9 @@
"persistent": true
}
},
"globalDependencies": [
"**/.env.*local"
],
"globalDependencies": ["**/.env.*local"],
"globalEnv": [
"APP_VERSION",
"NEXTAUTH_URL",
"NEXTAUTH_SECRET",
"NEXT_PUBLIC_APP_URL",
@ -33,6 +27,12 @@
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
"NEXT_PRIVATE_UPLOAD_ENDPOINT",
"NEXT_PRIVATE_UPLOAD_REGION",
"NEXT_PRIVATE_UPLOAD_BUCKET",
"NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID",
"NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY",
"NEXT_PRIVATE_SMTP_TRANSPORT",
"NEXT_PRIVATE_MAILCHANNELS_API_KEY",
"NEXT_PRIVATE_MAILCHANNELS_ENDPOINT",