Compare commits
39 Commits
fix/hide-u
...
feat/unive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be52e2fa3 | ||
|
|
0d702e9189 | ||
|
|
425db8fc1f | ||
|
|
2356f58e7b | ||
|
|
6c12ed4afc | ||
|
|
d76ee7f33c | ||
|
|
f8534b2c3d | ||
|
|
9014f01276 | ||
|
|
b255eb21e5 | ||
|
|
9a58178ea5 | ||
|
|
3c36eedfba | ||
|
|
46dfaa70a3 | ||
|
|
61da354a48 | ||
|
|
fbb332fb35 | ||
|
|
7e1cce9155 | ||
|
|
ed4cbe9fa6 | ||
|
|
599e857a1e | ||
|
|
581f08c59b | ||
|
|
24a2e9e6d4 | ||
|
|
e8796a7d86 | ||
|
|
db3f75c42f | ||
|
|
e8b5b3b24a | ||
|
|
00574325b9 | ||
|
|
99706e0ed6 | ||
|
|
326743d8a1 | ||
|
|
3f67b0f27e | ||
|
|
24036b0f24 | ||
|
|
fbf32404a6 | ||
|
|
975d52a07e | ||
|
|
898f5a629c | ||
|
|
27edcebef6 | ||
|
|
5862af3034 | ||
|
|
5969f148c8 | ||
|
|
660f5894a6 | ||
|
|
77058220a8 | ||
|
|
6cdba45396 | ||
|
|
67571158e8 | ||
|
|
171a5ba4ee | ||
|
|
a2ef9468ae |
14
.env.example
14
.env.example
@@ -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.
|
# 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"
|
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]]
|
# [[SMTP]]
|
||||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||||
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 'Building Documenso — Part 1: Certificates'
|
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.
|
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 world’s most open signing platform.
|
||||||
authorName: 'Timur Ercan'
|
authorName: 'Timur Ercan'
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
@@ -79,7 +79,7 @@ There weren’t any deeper reasons we choose WiseKey, other than they offered wh
|
|||||||
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>
|
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>
|
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>
|
Join the self-hoster community here: <a href="https://documen.so/discord" target="_blank">https://documen.so/discord</a>
|
||||||
|
|
||||||
Best from Hamburg
|
Best from Hamburg
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
Today, I'm pleased to share with you a preview of the next Documenso.
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ const { parsed: env } = require('dotenv').config({
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
experimental: {
|
||||||
|
serverActions: true,
|
||||||
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
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);
|
module.exports = withContentlayer(config);
|
||||||
|
|||||||
@@ -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 { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
||||||
import { redis } from '@documenso/lib/server-only/redis';
|
import { redis } from '@documenso/lib/server-only/redis';
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
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 { prisma } from '@documenso/prisma';
|
||||||
import {
|
import {
|
||||||
|
DocumentDataType,
|
||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
FieldType,
|
FieldType,
|
||||||
ReadStatus,
|
ReadStatus,
|
||||||
@@ -85,16 +88,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const now = new Date();
|
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({
|
const document = await prisma.document.create({
|
||||||
data: {
|
data: {
|
||||||
title: 'Documenso Supporter Pledge.pdf',
|
title: 'Documenso Supporter Pledge.pdf',
|
||||||
status: DocumentStatus.COMPLETED,
|
status: DocumentStatus.COMPLETED,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
|
documentDataId,
|
||||||
created: now,
|
},
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
throw new Error(`Document ${document.id} has no document data`);
|
||||||
|
}
|
||||||
|
|
||||||
const recipient = await prisma.recipient.create({
|
const recipient = await prisma.recipient.create({
|
||||||
data: {
|
data: {
|
||||||
name: user.name ?? '',
|
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) {
|
if (signatureDataUrl) {
|
||||||
document.document = await insertImageInPDF(
|
pdfData = await insertImageInPDF(
|
||||||
document.document,
|
pdfData,
|
||||||
signatureDataUrl,
|
signatureDataUrl,
|
||||||
Number(field.positionX),
|
Number(field.positionX),
|
||||||
Number(field.positionY),
|
Number(field.positionY),
|
||||||
field.page,
|
field.page,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
document.document = await insertTextInPDF(
|
pdfData = await insertTextInPDF(
|
||||||
document.document,
|
pdfData,
|
||||||
signatureText ?? '',
|
signatureText ?? '',
|
||||||
Number(field.positionX),
|
Number(field.positionX),
|
||||||
Number(field.positionY),
|
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([
|
await Promise.all([
|
||||||
prisma.signature.create({
|
prisma.signature.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -148,12 +179,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
typedSignature: signatureDataUrl ? '' : signatureText,
|
typedSignature: signatureDataUrl ? '' : signatureText,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.document.update({
|
prisma.documentData.update({
|
||||||
where: {
|
where: {
|
||||||
id: document.id,
|
id: documentData.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
document: document.document,
|
data: newData,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
const { parsed: env } = require('dotenv').config({
|
const { parsed: env } = require('dotenv').config({
|
||||||
path: path.join(__dirname, '../../.env.local'),
|
path: path.join(__dirname, '../../.env.local'),
|
||||||
@@ -9,6 +10,7 @@ const { parsed: env } = require('dotenv').config({
|
|||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: true,
|
serverActions: true,
|
||||||
|
serverActionsBodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
@@ -18,7 +20,9 @@ const config = {
|
|||||||
'@documenso/ui',
|
'@documenso/ui',
|
||||||
'@documenso/email',
|
'@documenso/email',
|
||||||
],
|
],
|
||||||
env,
|
env: {
|
||||||
|
APP_VERSION: version,
|
||||||
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.214.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"nanoid": "^4.0.2",
|
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-auth": "4.22.3",
|
"next-auth": "4.22.3",
|
||||||
"next-plausible": "^3.10.1",
|
"next-plausible": "^3.10.1",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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>;
|
|
||||||
30
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal file
30
apps/web/src/app/(dashboard)/admin/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/web/src/app/(dashboard)/admin/nav.tsx
Normal file
47
apps/web/src/app/(dashboard)/admin/nav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
5
apps/web/src/app/(dashboard)/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
redirect('/admin/stats');
|
||||||
|
}
|
||||||
75
apps/web/src/app/(dashboard)/admin/stats/page.tsx
Normal file
75
apps/web/src/app/(dashboard)/admin/stats/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,8 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
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 { 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';
|
||||||
@@ -28,9 +29,10 @@ import { completeDocument } from '~/components/forms/edit-document/add-subject.a
|
|||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
document: Document;
|
document: DocumentWithData;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
dataUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||||
@@ -41,14 +43,13 @@ export const EditDocumentForm = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
user: _user,
|
user: _user,
|
||||||
|
dataUrl,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||||
|
|
||||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Signers',
|
title: 'Add Signers',
|
||||||
@@ -136,7 +137,7 @@ export const EditDocumentForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/dashboard');
|
router.push('/documents');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@@ -151,11 +152,11 @@ export const EditDocumentForm = ({
|
|||||||
return (
|
return (
|
||||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
<Card
|
<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
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer document={documentUrl} />
|
<LazyPDFViewer document={dataUrl} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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 { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
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 { 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 { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
@@ -36,10 +37,16 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
userId: session.id,
|
userId: session.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document) {
|
if (!document || !document.documentData) {
|
||||||
redirect('/documents');
|
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([
|
const [recipients, fields] = await Promise.all([
|
||||||
await getRecipientsForDocument({
|
await getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
@@ -86,12 +93,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
user={session}
|
user={session}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
dataUrl={documentDataUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
|
<LazyPDFViewer document={documentDataUrl} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/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 { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
import { trpc } from '@documenso/trpc/client';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -47,17 +50,26 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
const onDownloadClick = () => {
|
const onDownloadClick = async () => {
|
||||||
let decodedDocument = row.document;
|
let document: DocumentWithData | null = null;
|
||||||
|
|
||||||
try {
|
if (!recipient) {
|
||||||
decodedDocument = atob(decodedDocument);
|
document = await trpc.document.getDocumentById.query({
|
||||||
} catch (err) {
|
id: row.id,
|
||||||
// We're just going to ignore this error and try to download the document
|
});
|
||||||
console.error(err);
|
} 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], {
|
const blob = new Blob([documentBytes], {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
|
|||||||
56
apps/web/src/app/(dashboard)/documents/data-table-title.tsx
Normal file
56
apps/web/src/app/(dashboard)/documents/data-table-title.tsx
Normal 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>
|
||||||
|
));
|
||||||
|
};
|
||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-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 { 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 { DataTableActionButton } from './data-table-action-button';
|
||||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
|
import { DataTableTitle } from './data-table-title';
|
||||||
|
|
||||||
export type DocumentsDataTableProps = {
|
export type DocumentsDataTableProps = {
|
||||||
results: FindResultSet<
|
results: FindResultSet<
|
||||||
@@ -29,6 +29,7 @@ export type DocumentsDataTableProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
@@ -42,25 +43,22 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
header: 'ID',
|
header: 'Created',
|
||||||
accessorKey: 'id',
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
@@ -74,11 +72,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: 'Created',
|
|
||||||
accessorKey: 'created',
|
|
||||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -95,7 +88,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
totalPages={results.totalPages}
|
totalPages={results.totalPages}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination table={table} />}
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
{isPending && (
|
{isPending && (
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-
|
|||||||
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
import { UploadDocument } from '../dashboard/upload-document';
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
import { DocumentsDataTable } from './data-table';
|
||||||
|
import { UploadDocument } from './upload-document';
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
@@ -39,7 +39,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
status,
|
status,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
column: 'created',
|
column: 'createdAt',
|
||||||
direction: 'desc',
|
direction: 'desc',
|
||||||
},
|
},
|
||||||
page,
|
page,
|
||||||
@@ -81,6 +81,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
{value !== ExtendedDocumentStatus.ALL && (
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||||
{Math.min(stats[value], 99)}
|
{Math.min(stats[value], 99)}
|
||||||
|
{stats[value] > 99 && '+'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCreateDocument } from '~/api/document/create/fetcher';
|
|
||||||
|
|
||||||
export type UploadDocumentProps = {
|
export type UploadDocumentProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||||
const { toast } = useToast();
|
|
||||||
const router = useRouter();
|
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) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const { type, data } = await putFile(file);
|
||||||
|
|
||||||
|
const { id: documentDataId } = await createDocumentData({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
const { id } = await createDocument({
|
const { id } = await createDocument({
|
||||||
file: file,
|
title: file.name,
|
||||||
|
documentDataId,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -41,6 +57,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
description: 'An error occurred while uploading your document.',
|
description: 'An error occurred while uploading your document.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,16 +21,18 @@ export default async function BillingSettingsPage() {
|
|||||||
redirect('/settings/profile');
|
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 we don't have a customer record, create one as well as an empty subscription.
|
||||||
if (!subscription?.customerId) {
|
return createCustomer({ user });
|
||||||
subscription = await createCustomer({ user });
|
});
|
||||||
}
|
|
||||||
|
|
||||||
let billingPortalUrl = '';
|
let billingPortalUrl = '';
|
||||||
|
|
||||||
if (subscription?.customerId) {
|
if (subscription.customerId) {
|
||||||
billingPortalUrl = await getPortalSession({
|
billingPortalUrl = await getPortalSession({
|
||||||
customerId: subscription.customerId,
|
customerId: subscription.customerId,
|
||||||
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
||||||
|
|||||||
@@ -1,55 +1,64 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes, useState } from 'react';
|
||||||
|
|
||||||
import { Download } from 'lucide-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 { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
document?: string;
|
documentData?: DocumentData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadButton = ({
|
export const DownloadButton = ({
|
||||||
className,
|
className,
|
||||||
fileName,
|
fileName,
|
||||||
document,
|
documentData,
|
||||||
disabled,
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: DownloadButtonProps) => {
|
}: DownloadButtonProps) => {
|
||||||
/**
|
const { toast } = useToast();
|
||||||
* Convert the document from base64 to a blob and download it.
|
|
||||||
*/
|
|
||||||
const onDownloadClick = () => {
|
|
||||||
if (!document) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let decodedDocument = document;
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const onDownloadClick = async () => {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
// We're just going to ignore this error and try to download the document
|
|
||||||
console.error(err);
|
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 (
|
return (
|
||||||
@@ -57,8 +66,9 @@ export const DownloadButton = ({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={className}
|
className={className}
|
||||||
disabled={disabled || !document}
|
disabled={disabled || !documentData}
|
||||||
onClick={onDownloadClick}
|
onClick={onDownloadClick}
|
||||||
|
loading={isLoading}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-5 w-5" />
|
<Download className="mr-2 h-5 w-5" />
|
||||||
|
|||||||
@@ -30,15 +30,21 @@ export default async function CompletedSigningPage({
|
|||||||
token,
|
token,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document) {
|
if (!document || !document.documentData) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
const [fields, recipient] = await Promise.all([
|
const [fields, recipient] = await Promise.all([
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const recipientName =
|
const recipientName =
|
||||||
recipient.name ||
|
recipient.name ||
|
||||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||||
@@ -91,7 +97,7 @@ export default async function CompletedSigningPage({
|
|||||||
<DownloadButton
|
<DownloadButton
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
fileName={document.title}
|
fileName={document.title}
|
||||||
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined}
|
documentData={documentData}
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
|
|||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
@@ -36,17 +37,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
token,
|
token,
|
||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
viewedDocument({ token }),
|
viewedDocument({ token }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!document) {
|
if (!document || !document.documentData || !recipient) {
|
||||||
return notFound();
|
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 (
|
return (
|
||||||
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
|
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
|
||||||
@@ -67,7 +72,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer document={documentUrl} />
|
<LazyPDFViewer document={documentDataUrl} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Suspense } from 'react';
|
|||||||
|
|
||||||
import { Caveat, Inter } from 'next/font/google';
|
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 { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||||
@@ -45,6 +47,8 @@ export const metadata = {
|
|||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const flags = await getServerComponentAllFlags();
|
const flags = await getServerComponentAllFlags();
|
||||||
|
|
||||||
|
const locale = getLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
@@ -63,16 +67,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<FeatureFlagProvider initialFlags={flags}>
|
<LocaleProvider locale={locale}>
|
||||||
<PlausibleProvider>
|
<FeatureFlagProvider initialFlags={flags}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<PlausibleProvider>
|
||||||
<TooltipProvider>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TrpcProvider>{children}</TrpcProvider>
|
<TooltipProvider>
|
||||||
</TooltipProvider>
|
<TrpcProvider>{children}</TrpcProvider>
|
||||||
</ThemeProvider>
|
</TooltipProvider>
|
||||||
</PlausibleProvider>
|
</ThemeProvider>
|
||||||
<Toaster />
|
</PlausibleProvider>
|
||||||
</FeatureFlagProvider>
|
<Toaster />
|
||||||
|
</FeatureFlagProvider>
|
||||||
|
</LocaleProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type StackAvatarProps = {
|
|||||||
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
|
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||||
let classes = '';
|
let classes = '';
|
||||||
let zIndexClass = '';
|
let zIndexClass = '';
|
||||||
const firstClass = first ? '' : '-ml-3';
|
const firstClass = first ? '' : '-ml-3';
|
||||||
@@ -48,7 +48,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
|
|||||||
${firstClass}
|
${firstClass}
|
||||||
dark:border-border h-10 w-10 border-2 border-solid border-white`}
|
dark:border-border h-10 w-10 border-2 border-solid border-white`}
|
||||||
>
|
>
|
||||||
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
|
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { initials } from '@documenso/lib/client-only/recipient-initials';
|
|
||||||
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 { Recipient } from '@documenso/prisma/client';
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -56,7 +56,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
first={true}
|
first={true}
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={initials(recipient.name)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
first={true}
|
first={true}
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={initials(recipient.name)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
first={true}
|
first={true}
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={initials(recipient.name)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +107,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
first={true}
|
first={true}
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={initials(recipient.name)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500">{recipient.email}</span>
|
<span className="text-sm text-gray-500">{recipient.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { initials } from '@documenso/lib/client-only/recipient-initials';
|
|
||||||
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 { Recipient } from '@documenso/prisma/client';
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
@@ -26,7 +26,7 @@ export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
|
|||||||
first={first}
|
first={first}
|
||||||
zIndex={String(zIndex - index * 10)}
|
zIndex={String(zIndex - index * 10)}
|
||||||
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
|
type={lastItemText && index === 4 ? 'unsigned' : getRecipientType(recipient)}
|
||||||
fallbackText={lastItemText ? lastItemText : initials(recipient.name)}
|
fallbackText={lastItemText ? lastItemText : recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
@@ -17,10 +17,23 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
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 (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
|
UserCog,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
import { useTheme } from 'next-themes';
|
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 { User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -35,24 +38,21 @@ export type ProfileDropdownProps = {
|
|||||||
|
|
||||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
const { getFlag } = useFeatureFlags();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
const isBillingEnabled = getFlag('app_billing');
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
const initials =
|
const avatarFallback = user.name
|
||||||
user.name
|
? recipientInitials(user.name)
|
||||||
?.split(' ')
|
: user.email.slice(0, 1).toUpperCase();
|
||||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
|
||||||
.slice(0, 2)
|
|
||||||
.join('') ?? 'UK';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarFallback>{initials}</AvatarFallback>
|
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -60,6 +60,19 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
<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>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/profile" className="cursor-pointer">
|
<Link href="/settings/profile" className="cursor-pointer">
|
||||||
<LucideUser className="mr-2 h-4 w-4" />
|
<LucideUser className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -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="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-center">
|
||||||
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />}
|
{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>
|
</div>
|
||||||
|
|
||||||
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">
|
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">
|
||||||
|
|||||||
@@ -2,16 +2,31 @@
|
|||||||
|
|
||||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
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> & {
|
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
date: string | number | Date;
|
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(() => {
|
useEffect(() => {
|
||||||
setLocaleDate(new Date(date).toLocaleString());
|
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
|
||||||
}, [date]);
|
}, [date, format]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={className} {...props}>
|
<span className={className} {...props}>
|
||||||
|
|||||||
@@ -18,13 +18,15 @@ import { Input } 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 ErrorMessages = {
|
const ERROR_MESSAGES = {
|
||||||
[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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
@@ -37,9 +39,10 @@ export type SignInFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -61,7 +64,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: ErrorMessages[errorCode] ?? 'An unknown error occurred',
|
description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
|
||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
@@ -78,12 +81,10 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackUrl: '/documents',
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// throw new Error('Not implemented');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: 'An unknown error occurred',
|
||||||
@@ -95,8 +96,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
|
|
||||||
const onSignInWithGoogleClick = async () => {
|
const onSignInWithGoogleClick = async () => {
|
||||||
try {
|
try {
|
||||||
await signIn('google', { callbackUrl: '/dashboard' });
|
await signIn('google', { callbackUrl: LOGIN_REDIRECT_PATH });
|
||||||
// throw new Error('Not implemented');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'An unknown error occurred',
|
title: 'An unknown error occurred',
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { JWT, getToken } from 'next-auth/jwt';
|
import { JWT, getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
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';
|
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { redis } from '@documenso/lib/server-only/redis';
|
|||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import {
|
import {
|
||||||
|
DocumentDataType,
|
||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
FieldType,
|
FieldType,
|
||||||
ReadStatus,
|
ReadStatus,
|
||||||
@@ -85,16 +86,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
const now = new Date();
|
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({
|
const document = await prisma.document.create({
|
||||||
data: {
|
data: {
|
||||||
title: 'Documenso Supporter Pledge.pdf',
|
title: 'Documenso Supporter Pledge.pdf',
|
||||||
status: DocumentStatus.COMPLETED,
|
status: DocumentStatus.COMPLETED,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
|
documentDataId,
|
||||||
created: now,
|
},
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
throw new Error(`Document ${document.id} has no document data`);
|
||||||
|
}
|
||||||
|
|
||||||
const recipient = await prisma.recipient.create({
|
const recipient = await prisma.recipient.create({
|
||||||
data: {
|
data: {
|
||||||
name: user.name ?? '',
|
name: user.name ?? '',
|
||||||
@@ -122,16 +141,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (signatureDataUrl) {
|
if (signatureDataUrl) {
|
||||||
document.document = await insertImageInPDF(
|
documentData.data = await insertImageInPDF(
|
||||||
document.document,
|
documentData.data,
|
||||||
signatureDataUrl,
|
signatureDataUrl,
|
||||||
field.positionX.toNumber(),
|
field.positionX.toNumber(),
|
||||||
field.positionY.toNumber(),
|
field.positionY.toNumber(),
|
||||||
field.page,
|
field.page,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
document.document = await insertTextInPDF(
|
documentData.data = await insertTextInPDF(
|
||||||
document.document,
|
documentData.data,
|
||||||
signatureText ?? '',
|
signatureText ?? '',
|
||||||
field.positionX.toNumber(),
|
field.positionX.toNumber(),
|
||||||
field.positionY.toNumber(),
|
field.positionY.toNumber(),
|
||||||
@@ -153,7 +172,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
id: document.id,
|
id: document.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
document: document.document,
|
documentData: {
|
||||||
|
update: {
|
||||||
|
data: documentData.data,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
1808
package-lock.json
generated
1808
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,13 +16,12 @@
|
|||||||
"worker:test": "tsup worker/index.ts --format esm"
|
"worker:test": "tsup worker/index.ts --format esm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/tsconfig": "*",
|
|
||||||
"@documenso/tailwind-config": "*",
|
|
||||||
"@documenso/ui": "*",
|
|
||||||
"@react-email/components": "^0.0.7",
|
"@react-email/components": "^0.0.7",
|
||||||
"nodemailer": "^6.9.3"
|
"nodemailer": "^6.9.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@documenso/tsconfig": "*",
|
||||||
|
"@documenso/tailwind-config": "*",
|
||||||
"@types/nodemailer": "^6.4.8",
|
"@types/nodemailer": "^6.4.8",
|
||||||
"tsup": "^7.1.0"
|
"tsup": "^7.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,5 @@ const path = require('path');
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
content: [
|
content: [`templates/**/*.{ts,tsx}`],
|
||||||
`templates/**/*.{ts,tsx}`,
|
|
||||||
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
37
packages/lib/client-only/providers/locale.tsx
Normal file
37
packages/lib/client-only/providers/locale.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export const initials = (text: string) =>
|
|
||||||
text
|
|
||||||
?.split(' ')
|
|
||||||
.map((name: string) => name.slice(0, 1).toUpperCase())
|
|
||||||
.slice(0, 2)
|
|
||||||
.join('') ?? 'UK';
|
|
||||||
5
packages/lib/constants/time.ts
Normal file
5
packages/lib/constants/time.ts
Normal 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;
|
||||||
5
packages/lib/next-auth/guards/is-admin.ts
Normal file
5
packages/lib/next-auth/guards/is-admin.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Role, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
||||||
|
|
||||||
|
export { isAdmin };
|
||||||
@@ -12,10 +12,15 @@
|
|||||||
],
|
],
|
||||||
"scripts": {},
|
"scripts": {},
|
||||||
"dependencies": {
|
"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/email": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@next-auth/prisma-adapter": "1.0.7",
|
"@next-auth/prisma-adapter": "1.0.7",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
|
"@scure/base": "^1.1.3",
|
||||||
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
"@upstash/redis": "^1.20.6",
|
"@upstash/redis": "^1.20.6",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
|
|||||||
26
packages/lib/server-only/admin/get-documents-stats.ts
Normal file
26
packages/lib/server-only/admin/get-documents-stats.ts
Normal 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;
|
||||||
|
};
|
||||||
29
packages/lib/server-only/admin/get-recipients-stats.ts
Normal file
29
packages/lib/server-only/admin/get-recipients-stats.ts
Normal 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;
|
||||||
|
};
|
||||||
18
packages/lib/server-only/admin/get-users-stats.ts
Normal file
18
packages/lib/server-only/admin/get-users-stats.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
19
packages/lib/server-only/document/create-document.ts
Normal file
19
packages/lib/server-only/document/create-document.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -32,7 +32,7 @@ export const findDocuments = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const orderByColumn = orderBy?.column ?? 'created';
|
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||||
|
|
||||||
const termFilters = !term
|
const termFilters = !term
|
||||||
|
|||||||
@@ -11,5 +11,8 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
|
|||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const getDocumentAndSenderByToken = async ({
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
User: true,
|
User: true,
|
||||||
|
documentData: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
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';
|
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||||
|
|
||||||
export type SealDocumentOptions = {
|
export type SealDocumentOptions = {
|
||||||
@@ -18,8 +21,17 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
|||||||
where: {
|
where: {
|
||||||
id: documentId,
|
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) {
|
if (document.status !== DocumentStatus.COMPLETED) {
|
||||||
throw new Error(`Document ${document.id} has not been 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
|
// !: 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);
|
const doc = await PDFDocument.load(pdfData);
|
||||||
|
|
||||||
@@ -58,13 +70,20 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
|||||||
|
|
||||||
const pdfBytes = await doc.save();
|
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: {
|
where: {
|
||||||
id: document.id,
|
id: documentData.id,
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
document: Buffer.from(pdfBytes).toString('base64'),
|
data: newData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { nanoid } from 'nanoid';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { nanoid } from '../../universal/id';
|
||||||
|
|
||||||
export interface SetRecipientsForDocumentOptions {
|
export interface SetRecipientsForDocumentOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"extends": "@documenso/tsconfig/react-library.json",
|
"extends": "@documenso/tsconfig/react-library.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["@documenso/tsconfig/process-env.d.ts"]
|
||||||
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
|
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
|
||||||
"exclude": ["dist", "build", "node_modules"]
|
"exclude": ["dist", "build", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
5
packages/lib/universal/id.ts
Normal file
5
packages/lib/universal/id.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
|
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
|
||||||
|
|
||||||
|
export { nanoid } from 'nanoid';
|
||||||
22
packages/lib/universal/upload/delete-file.ts
Normal file
22
packages/lib/universal/upload/delete-file.ts
Normal 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);
|
||||||
|
};
|
||||||
51
packages/lib/universal/upload/get-file.ts
Normal file
51
packages/lib/universal/upload/get-file.ts
Normal 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;
|
||||||
|
};
|
||||||
59
packages/lib/universal/upload/put-file.ts
Normal file
59
packages/lib/universal/upload/put-file.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
104
packages/lib/universal/upload/server-actions.ts
Normal file
104
packages/lib/universal/upload/server-actions.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
58
packages/lib/universal/upload/update-file.ts
Normal file
58
packages/lib/universal/upload/update-file.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
12
packages/lib/utils/recipient-formatter.ts
Normal file
12
packages/lib/utils/recipient-formatter.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const recipientInitials = (text: string) =>
|
||||||
|
text
|
||||||
|
.split(' ')
|
||||||
|
.map((name: string) => name.slice(0, 1).toUpperCase())
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
export const recipientAbbreviation = (recipient: Recipient) => {
|
||||||
|
return recipientInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase();
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[];
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
@@ -13,6 +13,11 @@ enum IdentityProvider {
|
|||||||
GOOGLE
|
GOOGLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String?
|
name String?
|
||||||
@@ -21,6 +26,7 @@ model User {
|
|||||||
password String?
|
password String?
|
||||||
source String?
|
source String?
|
||||||
signature String?
|
signature String?
|
||||||
|
roles Role[] @default([USER])
|
||||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
@@ -85,15 +91,33 @@ enum DocumentStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Document {
|
model Document {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
created DateTime @default(now())
|
userId Int
|
||||||
userId Int
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
title String
|
||||||
title String
|
status DocumentStatus @default(DRAFT)
|
||||||
status DocumentStatus @default(DRAFT)
|
Recipient Recipient[]
|
||||||
document String
|
Field Field[]
|
||||||
Recipient Recipient[]
|
documentDataId String
|
||||||
Field Field[]
|
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 {
|
enum ReadStatus {
|
||||||
|
|||||||
5
packages/prisma/types/document-with-data.ts
Normal file
5
packages/prisma/types/document-with-data.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Document, DocumentData } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type DocumentWithData = Document & {
|
||||||
|
documentData?: DocumentData | null;
|
||||||
|
};
|
||||||
@@ -1,17 +1,81 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
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 { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-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 { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
|
|
||||||
import { authenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
|
ZCreateDocumentMutationSchema,
|
||||||
|
ZGetDocumentByIdQuerySchema,
|
||||||
|
ZGetDocumentByTokenQuerySchema,
|
||||||
ZSendDocumentMutationSchema,
|
ZSendDocumentMutationSchema,
|
||||||
ZSetFieldsForDocumentMutationSchema,
|
ZSetFieldsForDocumentMutationSchema,
|
||||||
ZSetRecipientsForDocumentMutationSchema,
|
ZSetRecipientsForDocumentMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
export const documentRouter = router({
|
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
|
setRecipientsForDocument: authenticatedProcedure
|
||||||
.input(ZSetRecipientsForDocumentMutationSchema)
|
.input(ZSetRecipientsForDocumentMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
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({
|
export const ZSetRecipientsForDocumentMutationSchema = z.object({
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
|
|||||||
7
packages/tsconfig/process-env.d.ts
vendored
7
packages/tsconfig/process-env.d.ts
vendored
@@ -13,6 +13,13 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: 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_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api';
|
||||||
|
|
||||||
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;
|
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"lint": "eslint \"**/*.ts*\""
|
"lint": "eslint \"**/*.ts*\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/tsconfig": "*",
|
"@documenso/tsconfig": "*",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"typescript": "^5.1.6"
|
"typescript": "^5.1.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/lib": "*",
|
||||||
"@radix-ui/react-accordion": "^1.1.1",
|
"@radix-ui/react-accordion": "^1.1.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.3",
|
"@radix-ui/react-alert-dialog": "^1.0.3",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.0.2",
|
"@radix-ui/react-aspect-ratio": "^1.0.2",
|
||||||
@@ -51,7 +53,6 @@
|
|||||||
"class-variance-authority": "^0.6.0",
|
"class-variance-authority": "^0.6.0",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"date-fns": "^2.30.0",
|
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.214.0",
|
"lucide-react": "^0.214.0",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
|
|||||||
@@ -1,19 +1,46 @@
|
|||||||
import { Table } from '@tanstack/react-table';
|
import { Table } from '@tanstack/react-table';
|
||||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||||
|
|
||||||
interface DataTablePaginationProps<TData> {
|
interface DataTablePaginationProps<TData> {
|
||||||
table: Table<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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-4 px-2">
|
<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">
|
<div className="text-muted-foreground flex-1 text-sm">
|
||||||
{table.getFilteredSelectedRowModel().rows.length} of{' '}
|
{match(additionalInformation)
|
||||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
.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>
|
||||||
|
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { Caveat } from 'next/font/google';
|
import { Caveat } from 'next/font/google';
|
||||||
|
|
||||||
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
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 { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||||
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';
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import React, { useId } from 'react';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Plus, Trash } from 'lucide-react';
|
import { Plus, Trash } from 'lucide-react';
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { Field, Recipient, SendStatus } from '@documenso/prisma/client';
|
import { Field, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
|
|||||||
20
turbo.json
20
turbo.json
@@ -2,13 +2,8 @@
|
|||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": [
|
"dependsOn": ["^build"],
|
||||||
"^build"
|
"outputs": [".next/**", "!.next/cache/**"]
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".next/**",
|
|
||||||
"!.next/cache/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"lint": {},
|
"lint": {},
|
||||||
"dev": {
|
"dev": {
|
||||||
@@ -16,10 +11,9 @@
|
|||||||
"persistent": true
|
"persistent": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalDependencies": [
|
"globalDependencies": ["**/.env.*local"],
|
||||||
"**/.env.*local"
|
|
||||||
],
|
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
|
"APP_VERSION",
|
||||||
"NEXTAUTH_URL",
|
"NEXTAUTH_URL",
|
||||||
"NEXTAUTH_SECRET",
|
"NEXTAUTH_SECRET",
|
||||||
"NEXT_PUBLIC_APP_URL",
|
"NEXT_PUBLIC_APP_URL",
|
||||||
@@ -33,6 +27,12 @@
|
|||||||
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
|
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
|
||||||
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
||||||
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
|
"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_SMTP_TRANSPORT",
|
||||||
"NEXT_PRIVATE_MAILCHANNELS_API_KEY",
|
"NEXT_PRIVATE_MAILCHANNELS_API_KEY",
|
||||||
"NEXT_PRIVATE_MAILCHANNELS_ENDPOINT",
|
"NEXT_PRIVATE_MAILCHANNELS_ENDPOINT",
|
||||||
|
|||||||
Reference in New Issue
Block a user