Compare commits
20 Commits
v1.1
...
exp/millio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599dc9bd98 | ||
|
|
f270ea4b8e | ||
|
|
8048c29480 | ||
|
|
84b958d5b7 | ||
|
|
d8688692f7 | ||
|
|
8230349114 | ||
|
|
c054fc78a4 | ||
|
|
5de0c464f0 | ||
|
|
9444e0cc67 | ||
|
|
be0fe079a3 | ||
|
|
fbbc3b89c3 | ||
|
|
6c73453542 | ||
|
|
17eeaa2d25 | ||
|
|
a8d49bb8b8 | ||
|
|
e077c36fe4 | ||
|
|
7ce4cf8381 | ||
|
|
cebdf5fd8e | ||
|
|
8adc44802f | ||
|
|
06714a2aeb | ||
|
|
1c9cec1e93 |
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -14,8 +14,8 @@ env:
|
|||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build_app:
|
||||||
name: Build
|
name: Build App
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -37,3 +37,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
build_docker:
|
||||||
|
name: Build Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Build Docker Image
|
||||||
|
run: ./docker/build.sh
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,12 @@ git clone https://github.com/documenso/documenso
|
|||||||
|
|
||||||
We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso)
|
We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso)
|
||||||
|
|
||||||
|
### Video walkthrough
|
||||||
|
|
||||||
|
If you're a visual learner and prefer to watch a video walkthrough of setting up Documenso locally, check out this video:
|
||||||
|
|
||||||
|
[](https://youtu.be/Y0ppIQrEnZs)
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
|
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const path = require('path');
|
import dotenv from 'dotenv';
|
||||||
const { withContentlayer } = require('next-contentlayer');
|
import fs from 'fs';
|
||||||
|
import million from 'million/compiler';
|
||||||
|
import { withContentlayer } from 'next-contentlayer';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||||
|
|
||||||
ENV_FILES.forEach((file) => {
|
ENV_FILES.forEach((file) => {
|
||||||
require('dotenv').config({
|
dotenv.config({
|
||||||
path: path.join(__dirname, `../../${file}`),
|
path: path.join(__dirname, `../../${file}`),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// !: This is a temp hack to get caveat working without placing it back in the public directory.
|
||||||
|
// !: By inlining this at build time we should be able to sign faster.
|
||||||
|
const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -17,9 +31,16 @@ const config = {
|
|||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
transpilePackages: [
|
||||||
|
'@documenso/assets',
|
||||||
|
'@documenso/lib',
|
||||||
|
'@documenso/tailwind-config',
|
||||||
|
'@documenso/trpc',
|
||||||
|
'@documenso/ui',
|
||||||
|
],
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_PROJECT: 'marketing',
|
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||||
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
@@ -34,7 +55,7 @@ const config = {
|
|||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
async headers() {
|
headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/:path*',
|
source: '/:path*',
|
||||||
@@ -68,7 +89,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
async rewrites() {
|
rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/ingest/:path*',
|
source: '/ingest/:path*',
|
||||||
@@ -78,4 +99,8 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withContentlayer(config);
|
export default million.next(withContentlayer(config), {
|
||||||
|
auto: {
|
||||||
|
rsc: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/assets": "*",
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
|
"million": "^2.6.4",
|
||||||
"next": "14.0.0",
|
"next": "14.0.0",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.3",
|
||||||
"next-contentlayer": "^0.3.4",
|
"next-contentlayer": "^0.3.4",
|
||||||
|
|||||||
@@ -8,23 +8,23 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
|
||||||
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainer,
|
DocumentFlowFormContainer,
|
||||||
DocumentFlowFormContainerHeader,
|
DocumentFlowFormContainerHeader,
|
||||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
|
||||||
|
|
||||||
type SinglePlayerModeStep = 'fields' | 'sign';
|
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||||
|
|
||||||
// !: This entire file is a hack to get around failed prerendering of
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
@@ -41,6 +41,9 @@ export const SinglePlayerClient = () => {
|
|||||||
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
|
||||||
const [fields, setFields] = useState<Field[]>([]);
|
const [fields, setFields] = useState<Field[]>([]);
|
||||||
|
|
||||||
|
const { mutateAsync: createSinglePlayerDocument } =
|
||||||
|
trpc.singleplayer.createSinglePlayerDocument.useMutation();
|
||||||
|
|
||||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add document',
|
title: 'Add document',
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Moon, Sun } from 'lucide-react';
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import { FaXTwitter } from 'react-icons/fa6';
|
import { FaXTwitter } from 'react-icons/fa6';
|
||||||
import { LiaDiscord } from 'react-icons/lia';
|
import { LiaDiscord } from 'react-icons/lia';
|
||||||
import { LuGithub } from 'react-icons/lu';
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
import LogoImage from '@documenso/assets/logo.png';
|
import LogoImage from '@documenso/assets/logo.png';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
@@ -36,8 +35,6 @@ const FOOTER_LINKS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||||
const { setTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<div className={cn('border-t py-12', className)} {...props}>
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
||||||
@@ -79,21 +76,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24">
|
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-8 md:mt-12 lg:mt-24">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
<div className="flex flex-wrap">
|
||||||
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
|
<ThemeSwitcher />
|
||||||
<Sun className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Light</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
|
|
||||||
<Moon className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Dark</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { createElement } from 'react';
|
|
||||||
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { PDFDocument } from 'pdf-lib';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { mailer } from '@documenso/email/mailer';
|
|
||||||
import { render } from '@documenso/email/render';
|
|
||||||
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
|
||||||
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
|
||||||
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
|
||||||
import { alphaid } from '@documenso/lib/universal/id';
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import {
|
|
||||||
DocumentDataType,
|
|
||||||
DocumentStatus,
|
|
||||||
FieldType,
|
|
||||||
Prisma,
|
|
||||||
ReadStatus,
|
|
||||||
SendStatus,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@documenso/prisma/client';
|
|
||||||
import { signPdf } from '@documenso/signing';
|
|
||||||
|
|
||||||
const ZCreateSinglePlayerDocumentSchema = z.object({
|
|
||||||
documentData: z.object({
|
|
||||||
data: z.string(),
|
|
||||||
type: z.nativeEnum(DocumentDataType),
|
|
||||||
}),
|
|
||||||
documentName: z.string(),
|
|
||||||
signer: z.object({
|
|
||||||
email: z.string().email().min(1),
|
|
||||||
name: z.string(),
|
|
||||||
signature: z.string(),
|
|
||||||
}),
|
|
||||||
fields: z.array(
|
|
||||||
z.object({
|
|
||||||
page: z.number(),
|
|
||||||
type: z.nativeEnum(FieldType),
|
|
||||||
positionX: z.number(),
|
|
||||||
positionY: z.number(),
|
|
||||||
width: z.number(),
|
|
||||||
height: z.number(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and self signs a document.
|
|
||||||
*
|
|
||||||
* Returns the document token.
|
|
||||||
*/
|
|
||||||
export const createSinglePlayerDocument = async (
|
|
||||||
value: TCreateSinglePlayerDocumentSchema,
|
|
||||||
): Promise<string> => {
|
|
||||||
const { signer, fields, documentData, documentName } =
|
|
||||||
ZCreateSinglePlayerDocumentSchema.parse(value);
|
|
||||||
|
|
||||||
const document = await getFile({
|
|
||||||
data: documentData.data,
|
|
||||||
type: documentData.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const doc = await PDFDocument.load(document);
|
|
||||||
const createdAt = new Date();
|
|
||||||
|
|
||||||
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
|
||||||
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
|
|
||||||
const typedSignature = !isBase64 ? signer.signature : null;
|
|
||||||
|
|
||||||
// Update the document with the fields inserted.
|
|
||||||
for (const field of fields) {
|
|
||||||
const isSignatureField = field.type === FieldType.SIGNATURE;
|
|
||||||
|
|
||||||
await insertFieldInPDF(doc, {
|
|
||||||
...mapField(field, signer),
|
|
||||||
Signature: isSignatureField
|
|
||||||
? {
|
|
||||||
created: createdAt,
|
|
||||||
signatureImageAsBase64,
|
|
||||||
typedSignature,
|
|
||||||
// Dummy data.
|
|
||||||
id: -1,
|
|
||||||
recipientId: -1,
|
|
||||||
fieldId: -1,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
// Dummy data.
|
|
||||||
id: -1,
|
|
||||||
documentId: -1,
|
|
||||||
recipientId: -1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsignedPdfBytes = await doc.save();
|
|
||||||
|
|
||||||
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
|
|
||||||
|
|
||||||
const { token } = await prisma.$transaction(
|
|
||||||
async (tx) => {
|
|
||||||
const token = alphaid();
|
|
||||||
|
|
||||||
// Fetch service user who will be the owner of the document.
|
|
||||||
const serviceUser = await tx.user.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
email: SERVICE_USER_EMAIL,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { id: documentDataId } = await putFile({
|
|
||||||
name: `${documentName}.pdf`,
|
|
||||||
type: 'application/pdf',
|
|
||||||
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create document.
|
|
||||||
const document = await tx.document.create({
|
|
||||||
data: {
|
|
||||||
title: documentName,
|
|
||||||
status: DocumentStatus.COMPLETED,
|
|
||||||
documentDataId,
|
|
||||||
userId: serviceUser.id,
|
|
||||||
createdAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create recipient.
|
|
||||||
const recipient = await tx.recipient.create({
|
|
||||||
data: {
|
|
||||||
documentId: document.id,
|
|
||||||
name: signer.name,
|
|
||||||
email: signer.email,
|
|
||||||
token,
|
|
||||||
signedAt: createdAt,
|
|
||||||
readStatus: ReadStatus.OPENED,
|
|
||||||
signingStatus: SigningStatus.SIGNED,
|
|
||||||
sendStatus: SendStatus.SENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create fields and signatures.
|
|
||||||
await Promise.all(
|
|
||||||
fields.map(async (field) => {
|
|
||||||
const insertedField = await tx.field.create({
|
|
||||||
data: {
|
|
||||||
documentId: document.id,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
...mapField(field, signer),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
|
||||||
await tx.signature.create({
|
|
||||||
data: {
|
|
||||||
fieldId: insertedField.id,
|
|
||||||
signatureImageAsBase64,
|
|
||||||
typedSignature,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { document, token };
|
|
||||||
},
|
|
||||||
{
|
|
||||||
maxWait: 5000,
|
|
||||||
timeout: 30000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const template = createElement(DocumentSelfSignedEmailTemplate, {
|
|
||||||
documentName: documentName,
|
|
||||||
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send email to signer.
|
|
||||||
await mailer.sendMail({
|
|
||||||
to: {
|
|
||||||
address: signer.email,
|
|
||||||
name: signer.name,
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
name: FROM_NAME,
|
|
||||||
address: FROM_ADDRESS,
|
|
||||||
},
|
|
||||||
subject: 'Document signed',
|
|
||||||
html: render(template),
|
|
||||||
text: render(template, { plainText: true }),
|
|
||||||
attachments: [{ content: signedPdfBuffer, filename: documentName }],
|
|
||||||
});
|
|
||||||
|
|
||||||
return token;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map the fields provided by the user to fields compatible with Prisma.
|
|
||||||
*
|
|
||||||
* Signature fields are handled separately.
|
|
||||||
*
|
|
||||||
* @param field The field passed in by the user.
|
|
||||||
* @param signer The details of the person who is signing this document.
|
|
||||||
* @returns A field compatible with Prisma.
|
|
||||||
*/
|
|
||||||
const mapField = (
|
|
||||||
field: TCreateSinglePlayerDocumentSchema['fields'][number],
|
|
||||||
signer: TCreateSinglePlayerDocumentSchema['signer'],
|
|
||||||
) => {
|
|
||||||
const customText = match(field.type)
|
|
||||||
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
|
|
||||||
.with(FieldType.EMAIL, () => signer.email)
|
|
||||||
.with(FieldType.NAME, () => signer.name)
|
|
||||||
.otherwise(() => '');
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: field.type,
|
|
||||||
page: field.page,
|
|
||||||
positionX: new Prisma.Decimal(field.positionX),
|
|
||||||
positionY: new Prisma.Decimal(field.positionY),
|
|
||||||
width: new Prisma.Decimal(field.width),
|
|
||||||
height: new Prisma.Decimal(field.height),
|
|
||||||
customText,
|
|
||||||
inserted: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -2,6 +2,10 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next';
|
|||||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||||
import { appRouter } from '@documenso/trpc/server/router';
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
maxDuration: 60,
|
||||||
|
};
|
||||||
|
|
||||||
export default trpcNext.createNextApiHandler({
|
export default trpcNext.createNextApiHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
"**/*.mjs",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { version } = require('./package.json');
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
@@ -10,6 +11,12 @@ ENV_FILES.forEach((file) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// !: This is a temp hack to get caveat working without placing it back in the public directory.
|
||||||
|
// !: By inlining this at build time we should be able to sign faster.
|
||||||
|
const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
@@ -19,15 +26,18 @@ const config = {
|
|||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
|
'@documenso/assets',
|
||||||
|
'@documenso/ee',
|
||||||
'@documenso/lib',
|
'@documenso/lib',
|
||||||
'@documenso/prisma',
|
'@documenso/prisma',
|
||||||
|
'@documenso/tailwind-config',
|
||||||
'@documenso/trpc',
|
'@documenso/trpc',
|
||||||
'@documenso/ui',
|
'@documenso/ui',
|
||||||
'@documenso/email',
|
|
||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
APP_VERSION: version,
|
APP_VERSION: version,
|
||||||
NEXT_PUBLIC_PROJECT: 'web',
|
NEXT_PUBLIC_PROJECT: 'web',
|
||||||
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/assets": "*",
|
||||||
"@documenso/ee": "*",
|
"@documenso/ee": "*",
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
@@ -54,11 +55,14 @@ export const ResendDocumentActionItem = ({
|
|||||||
document,
|
document,
|
||||||
recipients,
|
recipients,
|
||||||
}: ResendDocumentActionItemProps) => {
|
}: ResendDocumentActionItemProps) => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const isOwner = document.userId === session?.user?.id;
|
||||||
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
|
!isOwner ||
|
||||||
document.status !== 'PENDING' ||
|
document.status !== 'PENDING' ||
|
||||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||||
|
|
||||||
|
|||||||
@@ -62,9 +62,10 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const link = window.document.createElement('a');
|
const link = window.document.createElement('a');
|
||||||
|
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
|
||||||
|
|
||||||
link.href = window.URL.createObjectURL(blob);
|
link.href = window.URL.createObjectURL(blob);
|
||||||
link.download = row.title || 'document.pdf';
|
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||||
|
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
|
|||||||
@@ -88,9 +88,10 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
const link = window.document.createElement('a');
|
const link = window.document.createElement('a');
|
||||||
|
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
|
||||||
|
|
||||||
link.href = window.URL.createObjectURL(blob);
|
link.href = window.URL.createObjectURL(blob);
|
||||||
link.download = row.title || 'document.pdf';
|
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||||
|
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { Loader } from 'lucide-react';
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
@@ -22,6 +23,7 @@ export type UploadDocumentProps = {
|
|||||||
|
|
||||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone
|
<DocumentDropzone
|
||||||
className="min-h-[40vh]"
|
className="min-h-[40vh]"
|
||||||
disabled={remaining.documents === 0}
|
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type PageProps = {
|
||||||
|
params: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mb-4 text-red-300">
|
||||||
|
<XOctagon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-4xl font-semibold">No token provided</h2>
|
||||||
|
<p className="text-muted-foreground mt-2 text-base">
|
||||||
|
It seems that there is no token provided. Please check your email and try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = await verifyEmail({ token });
|
||||||
|
|
||||||
|
if (verified === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
We were unable to verify your email. If your email is not verified already, please try
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
It seems that the provided token has expired. We've just sent you another token, please
|
||||||
|
check your email and try again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
Your email has been successfully confirmed! You can now use all features of Documenso.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/web/src/app/(unauthenticated)/verify-email/page.tsx
Normal file
28
apps/web/src/app/(unauthenticated)/verify-email/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export default function EmailVerificationWithoutTokenPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
It seems that there is no token provided, if you are trying to verify your email please
|
||||||
|
follow the link in your email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import { Toaster } from '@documenso/ui/primitives/toaster';
|
|||||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
import { ThemeProvider } from '~/providers/next-theme';
|
import { ThemeProvider } from '~/providers/next-theme';
|
||||||
import { PlausibleProvider } from '~/providers/plausible';
|
|
||||||
import { PostHogPageview } from '~/providers/posthog';
|
import { PostHogPageview } from '~/providers/posthog';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
@@ -69,13 +68,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<body>
|
<body>
|
||||||
<LocaleProvider locale={locale}>
|
<LocaleProvider locale={locale}>
|
||||||
<FeatureFlagProvider initialFlags={flags}>
|
<FeatureFlagProvider initialFlags={flags}>
|
||||||
<PlausibleProvider>
|
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<TrpcProvider>{children}</TrpcProvider>
|
<TrpcProvider>{children}</TrpcProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PlausibleProvider>
|
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</FeatureFlagProvider>
|
</FeatureFlagProvider>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { StackAvatar } from './stack-avatar';
|
||||||
|
|
||||||
|
export type AvatarWithRecipientProps = {
|
||||||
|
recipient: Recipient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||||
|
const [, copy] = useCopyToClipboard();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const onRecipientClick = () => {
|
||||||
|
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
|
||||||
|
toast({
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
description: 'The signing link has been copied to your clipboard.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-1 flex cursor-pointer items-center gap-2" onClick={onRecipientClick}>
|
||||||
|
<StackAvatar
|
||||||
|
first={true}
|
||||||
|
key={recipient.id}
|
||||||
|
type={getRecipientType(recipient)}
|
||||||
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-muted-foreground text-sm hover:underline"
|
||||||
|
title="Click to copy signing link for sending to recipient"
|
||||||
|
>
|
||||||
|
{recipient.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@documenso/ui/primitives/tooltip';
|
} from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
import { StackAvatars } from './stack-avatars';
|
import { StackAvatars } from './stack-avatars';
|
||||||
|
|
||||||
@@ -85,15 +86,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">Opened</h1>
|
<h1 className="text-base font-medium">Opened</h1>
|
||||||
{openedRecipients.map((recipient: Recipient) => (
|
{openedRecipients.map((recipient: Recipient) => (
|
||||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||||
<StackAvatar
|
|
||||||
first={true}
|
|
||||||
key={recipient.id}
|
|
||||||
type={getRecipientType(recipient)}
|
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -102,15 +95,7 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||||
<StackAvatar
|
|
||||||
first={true}
|
|
||||||
key={recipient.id}
|
|
||||||
type={getRecipientType(recipient)}
|
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes, useState } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
|||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
// const pathname = usePathname();
|
// const pathname = usePathname();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
||||||
|
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
|
||||||
|
|
||||||
|
setModifierKey(isMacOS ? '⌘' : 'Ctrl');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -33,8 +42,8 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 font-mono text-xs">
|
<div className="text-muted-foreground bg-muted flex items-center rounded-md px-1.5 py-0.5 text-xs tracking-wider">
|
||||||
Ctrl+K
|
{modifierKey}+K
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ONE_SECOND } from '@documenso/lib/constants/time';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type VerifyEmailBannerProps = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
|
||||||
|
|
||||||
|
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: sendConfirmationEmail, isLoading } =
|
||||||
|
trpc.profile.sendConfirmationEmail.useMutation();
|
||||||
|
|
||||||
|
const onResendConfirmationEmail = async () => {
|
||||||
|
try {
|
||||||
|
setIsButtonDisabled(true);
|
||||||
|
|
||||||
|
await sendConfirmationEmail({ email: email });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Verification email sent successfully.',
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
|
||||||
|
} catch (err) {
|
||||||
|
setIsButtonDisabled(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Something went wrong while sending the confirmation email.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check localStorage to see if we've recently automatically displayed the dialog
|
||||||
|
// if it was within the past 24 hours, don't show it again
|
||||||
|
// otherwise, show it again and update the localStorage timestamp
|
||||||
|
const emailVerificationDialogLastShown = localStorage.getItem(
|
||||||
|
'emailVerificationDialogLastShown',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (emailVerificationDialogLastShown) {
|
||||||
|
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||||
|
|
||||||
|
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
|
|
||||||
|
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-yellow-200 dark:bg-yellow-400">
|
||||||
|
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||||
|
Verify your email address to unlock all features.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isButtonDisabled ? 'Verification Email Sent' : 'Verify Now'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>Verify your email address</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox and
|
||||||
|
click the link in the email to verify your account.
|
||||||
|
</DialogDescription>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={onResendConfirmationEmail}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Sending...' : 'Resend Confirmation Email'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -20,9 +20,18 @@ import { FormErrorMessage } from '../form/form-error-message';
|
|||||||
|
|
||||||
export const ZPasswordFormSchema = z
|
export const ZPasswordFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
currentPassword: z.string().min(6).max(72),
|
currentPassword: z
|
||||||
password: z.string().min(6).max(72),
|
.string()
|
||||||
repeatedPassword: z.string().min(6).max(72),
|
.min(6, { message: 'Password should contain at least 6 characters' })
|
||||||
|
.max(72, { message: 'Password should not contain more than 72 characters' }),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, { message: 'Password should contain at least 6 characters' })
|
||||||
|
.max(72, { message: 'Password should not contain more than 72 characters' }),
|
||||||
|
repeatedPassword: z
|
||||||
|
.string()
|
||||||
|
.min(6, { message: 'Password should contain at least 6 characters' })
|
||||||
|
.max(72, { message: 'Password should not contain more than 72 characters' }),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.repeatedPassword, {
|
.refine((data) => data.password === data.repeatedPassword, {
|
||||||
message: 'Passwords do not match',
|
message: 'Passwords do not match',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ 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, { message: 'Invalid password' }).max(72),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export const ZSignUpFormSchema = z.object({
|
export const ZSignUpFormSchema = z.object({
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(6).max(72),
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, { message: 'Password should contain at least 6 characters' })
|
||||||
|
.max(72, { message: 'Password should not contain more than 72 characters' }),
|
||||||
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,6 +137,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
21
apps/web/src/pages/api/health.ts
Normal file
21
apps/web/src/pages/api/health.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
status: 'ok',
|
||||||
|
message: 'All systems operational',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next';
|
|||||||
import { createTrpcContext } from '@documenso/trpc/server/context';
|
import { createTrpcContext } from '@documenso/trpc/server/context';
|
||||||
import { appRouter } from '@documenso/trpc/server/router';
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
maxDuration: 60,
|
||||||
|
};
|
||||||
|
|
||||||
export default trpcNext.createNextApiHandler({
|
export default trpcNext.createNextApiHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||||
|
|||||||
1449
package-lock.json
generated
1449
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
|||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:web": "turbo run build --filter=@documenso/web",
|
"build:web": "turbo run build --filter=@documenso/web",
|
||||||
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
||||||
"start": "cd apps && cd web && next start",
|
"start": "turbo run start --filter=@documenso/web --filter=@documenso/marketing",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"lint:fix": "turbo run lint:fix",
|
"lint:fix": "turbo run lint:fix",
|
||||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"recharts": "^2.7.2",
|
||||||
"recharts": "^2.7.2"
|
"react-hotkeys-hook": "^4.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,12 +42,8 @@ const getTransport = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.NEXT_PRIVATE_SMTP_HOST) {
|
|
||||||
throw new Error('SMTP transport requires NEXT_PRIVATE_SMTP_HOST');
|
|
||||||
}
|
|
||||||
|
|
||||||
return createTransport({
|
return createTransport({
|
||||||
host: process.env.NEXT_PRIVATE_SMTP_HOST,
|
host: process.env.NEXT_PRIVATE_SMTP_HOST ?? 'localhost:2500',
|
||||||
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
|
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
|
||||||
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
|
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -17,16 +17,20 @@
|
|||||||
"worker:test": "tsup worker/index.ts --format esm"
|
"worker:test": "tsup worker/index.ts --format esm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/nodemailer-resend": "1.0.0",
|
"@documenso/nodemailer-resend": "2.0.0",
|
||||||
"@react-email/components": "^0.0.7",
|
"@react-email/components": "^0.0.11",
|
||||||
|
"@react-email/tailwind": "0.0.9",
|
||||||
"nodemailer": "^6.9.3",
|
"nodemailer": "^6.9.3",
|
||||||
"react-email": "^1.9.4",
|
"react-email": "^1.9.5",
|
||||||
"resend": "^1.1.0"
|
"resend": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/tsconfig": "*",
|
"@documenso/tsconfig": "*",
|
||||||
"@types/nodemailer": "^6.4.8",
|
"@types/nodemailer": "^6.4.8",
|
||||||
"tsup": "^7.1.0"
|
"tsup": "^7.1.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@react-email/tailwind": "0.0.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
|
export type TemplateConfirmationEmailProps = {
|
||||||
|
confirmationLink: string;
|
||||||
|
assetBaseUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateConfirmationEmail = ({
|
||||||
|
confirmationLink,
|
||||||
|
assetBaseUrl,
|
||||||
|
}: TemplateConfirmationEmailProps) => {
|
||||||
|
return (
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
|
<Section className="flex-row items-center justify-center">
|
||||||
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
|
Welcome to Documenso!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
Before you get started, please confirm your email address by clicking the button below:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-6 mt-8 text-center">
|
||||||
|
<Button
|
||||||
|
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||||
|
href={confirmationLink}
|
||||||
|
>
|
||||||
|
Confirm email
|
||||||
|
</Button>
|
||||||
|
<Text className="mt-8 text-center text-sm italic text-slate-400">
|
||||||
|
You can also copy and paste this link into your browser: {confirmationLink} (link
|
||||||
|
expires in 1 hour)
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
</Tailwind>
|
||||||
|
);
|
||||||
|
};
|
||||||
69
packages/email/templates/confirm-email.tsx
Normal file
69
packages/email/templates/confirm-email.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TemplateConfirmationEmail,
|
||||||
|
TemplateConfirmationEmailProps,
|
||||||
|
} from '../template-components/template-confirmation-email';
|
||||||
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
|
export const ConfirmEmailTemplate = ({
|
||||||
|
confirmationLink,
|
||||||
|
assetBaseUrl,
|
||||||
|
}: TemplateConfirmationEmailProps) => {
|
||||||
|
const previewText = `Please confirm your email address`;
|
||||||
|
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="mx-auto my-auto bg-white font-sans">
|
||||||
|
<Section>
|
||||||
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
|
<Section>
|
||||||
|
<Img
|
||||||
|
src={getAssetUrl('/static/logo.png')}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="mb-4 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateConfirmationEmail
|
||||||
|
confirmationLink={confirmationLink}
|
||||||
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
<div className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter isDocument={false} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
/// <reference types="../types/next-auth.d.ts" />
|
||||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||||
import { compare } from 'bcrypt';
|
import { compare } from 'bcrypt';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { AuthOptions, Session, User } from 'next-auth';
|
import type { AuthOptions, Session, User } from 'next-auth';
|
||||||
|
import type { JWT } from 'next-auth/jwt';
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||||
import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
|
import type { GoogleProfile } from 'next-auth/providers/google';
|
||||||
|
import GoogleProvider from 'next-auth/providers/google';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
id: Number(user.id),
|
id: Number(user.id),
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
emailVerified: user.emailVerified?.toISOString() ?? null,
|
||||||
} satisfies User;
|
} satisfies User;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -61,6 +65,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
id: Number(profile.sub),
|
id: Number(profile.sub),
|
||||||
name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(),
|
name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(),
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
|
emailVerified: profile.email_verified ? new Date().toISOString() : null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -70,9 +75,10 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
const merged = {
|
const merged = {
|
||||||
...token,
|
...token,
|
||||||
...user,
|
...user,
|
||||||
};
|
emailVerified: user?.emailVerified ? new Date(user.emailVerified).toISOString() : null,
|
||||||
|
} satisfies JWT;
|
||||||
|
|
||||||
if (!merged.email) {
|
if (!merged.email || typeof merged.emailVerified !== 'string') {
|
||||||
const userId = Number(merged.id ?? token.sub);
|
const userId = Number(merged.id ?? token.sub);
|
||||||
|
|
||||||
const retrieved = await prisma.user.findFirst({
|
const retrieved = await prisma.user.findFirst({
|
||||||
@@ -88,6 +94,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
merged.id = retrieved.id;
|
merged.id = retrieved.id;
|
||||||
merged.name = retrieved.name;
|
merged.name = retrieved.name;
|
||||||
merged.email = retrieved.email;
|
merged.email = retrieved.email;
|
||||||
|
merged.emailVerified = retrieved.emailVerified?.toISOString() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -97,7 +104,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
) {
|
) {
|
||||||
merged.lastSignedIn = new Date().toISOString();
|
merged.lastSignedIn = new Date().toISOString();
|
||||||
|
|
||||||
await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: Number(merged.id),
|
id: Number(merged.id),
|
||||||
},
|
},
|
||||||
@@ -105,6 +112,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
lastSignedIn: merged.lastSignedIn,
|
lastSignedIn: merged.lastSignedIn,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -112,7 +121,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
name: merged.name,
|
name: merged.name,
|
||||||
email: merged.email,
|
email: merged.email,
|
||||||
lastSignedIn: merged.lastSignedIn,
|
lastSignedIn: merged.lastSignedIn,
|
||||||
};
|
emailVerified: merged.emailVerified,
|
||||||
|
} satisfies JWT;
|
||||||
},
|
},
|
||||||
|
|
||||||
session({ token, session }) {
|
session({ token, session }) {
|
||||||
@@ -123,6 +133,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
id: Number(token.id),
|
id: Number(token.id),
|
||||||
name: token.name,
|
name: token.name,
|
||||||
email: token.email,
|
email: token.email,
|
||||||
|
emailVerified: token.emailVerified ?? null,
|
||||||
},
|
},
|
||||||
} satisfies Session;
|
} satisfies Session;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@aws-sdk/cloudfront-signer": "^3.410.0",
|
"@aws-sdk/cloudfront-signer": "^3.410.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||||
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
||||||
|
"@documenso/assets": "*",
|
||||||
"@documenso/email": "*",
|
"@documenso/email": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@documenso/signing": "*",
|
"@documenso/signing": "*",
|
||||||
|
|||||||
56
packages/lib/server-only/auth/send-confirmation-email.ts
Normal file
56
packages/lib/server-only/auth/send-confirmation-email.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface SendConfirmationEmailProps {
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
VerificationToken: {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [verificationToken] = user.VerificationToken;
|
||||||
|
|
||||||
|
if (!verificationToken?.token) {
|
||||||
|
throw new Error('Verification token not found for the user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||||
|
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||||
|
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||||
|
|
||||||
|
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||||
|
assetBaseUrl,
|
||||||
|
confirmationLink,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: user.email,
|
||||||
|
name: user.name || '',
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: senderName,
|
||||||
|
address: senderAdress,
|
||||||
|
},
|
||||||
|
subject: 'Please confirm your email',
|
||||||
|
html: render(confirmationTemplate),
|
||||||
|
text: render(confirmationTemplate, { plainText: true }),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -2,10 +2,11 @@ import { DateTime } from 'luxon';
|
|||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { Document, Prisma, SigningStatus } from '@documenso/prisma/client';
|
import type { Document, Prisma } from '@documenso/prisma/client';
|
||||||
|
import { SigningStatus } from '@documenso/prisma/client';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
import { FindResultSet } from '../../types/find-result-set';
|
import type { FindResultSet } from '../../types/find-result-set';
|
||||||
|
|
||||||
export interface FindDocumentsOptions {
|
export interface FindDocumentsOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -160,19 +161,11 @@ export const findDocuments = async ({
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const maskedData = data.map((doc) => ({
|
|
||||||
...doc,
|
|
||||||
Recipient: doc.Recipient.map((recipient) => ({
|
|
||||||
...recipient,
|
|
||||||
token: recipient.email === user.email ? recipient.token : '',
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: maskedData,
|
data,
|
||||||
count,
|
count,
|
||||||
currentPage: Math.max(page, 1),
|
currentPage: Math.max(page, 1),
|
||||||
perPage,
|
perPage,
|
||||||
totalPages: Math.ceil(count / perPage),
|
totalPages: Math.ceil(count / perPage),
|
||||||
} satisfies FindResultSet<typeof maskedData>;
|
} satisfies FindResultSet<typeof data>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import fontkit from '@pdf-lib/fontkit';
|
|||||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CAVEAT_FONT_PATH,
|
|
||||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||||
DEFAULT_STANDARD_FONT_SIZE,
|
DEFAULT_STANDARD_FONT_SIZE,
|
||||||
MIN_HANDWRITING_FONT_SIZE,
|
MIN_HANDWRITING_FONT_SIZE,
|
||||||
@@ -10,12 +9,12 @@ import {
|
|||||||
} from '@documenso/lib/constants/pdf';
|
} from '@documenso/lib/constants/pdf';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
|
||||||
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||||
// Fetch the font file from the public URL.
|
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
|
||||||
const fontResponse = await fetch(CAVEAT_FONT_PATH);
|
res.arrayBuffer(),
|
||||||
const fontCaveat = await fontResponse.arrayBuffer();
|
);
|
||||||
|
|
||||||
const isSignatureField = isSignatureFieldType(field.type);
|
const isSignatureField = isSignatureFieldType(field.type);
|
||||||
|
|
||||||
|
|||||||
41
packages/lib/server-only/user/generate-confirmation-token.ts
Normal file
41
packages/lib/server-only/user/generate-confirmation-token.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { ONE_HOUR } from '../../constants/time';
|
||||||
|
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||||
|
|
||||||
|
const IDENTIFIER = 'confirmation-email';
|
||||||
|
|
||||||
|
export const generateConfirmationToken = async ({ email }: { email: string }) => {
|
||||||
|
const token = crypto.randomBytes(20).toString('hex');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdToken = await prisma.verificationToken.create({
|
||||||
|
data: {
|
||||||
|
identifier: IDENTIFIER,
|
||||||
|
token: token,
|
||||||
|
expires: new Date(Date.now() + ONE_HOUR),
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createdToken) {
|
||||||
|
throw new Error(`Failed to create the verification token`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendConfirmationEmail({ userId: user.id });
|
||||||
|
};
|
||||||
41
packages/lib/server-only/user/send-confirmation-token.ts
Normal file
41
packages/lib/server-only/user/send-confirmation-token.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { ONE_HOUR } from '../../constants/time';
|
||||||
|
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||||
|
|
||||||
|
const IDENTIFIER = 'confirmation-email';
|
||||||
|
|
||||||
|
export const sendConfirmationToken = async ({ email }: { email: string }) => {
|
||||||
|
const token = crypto.randomBytes(20).toString('hex');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdToken = await prisma.verificationToken.create({
|
||||||
|
data: {
|
||||||
|
identifier: IDENTIFIER,
|
||||||
|
token: token,
|
||||||
|
expires: new Date(Date.now() + ONE_HOUR),
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createdToken) {
|
||||||
|
throw new Error(`Failed to create the verification token`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendConfirmationEmail({ userId: user.id });
|
||||||
|
};
|
||||||
70
packages/lib/server-only/user/verify-email.ts
Normal file
70
packages/lib/server-only/user/verify-email.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { sendConfirmationToken } from './send-confirmation-token';
|
||||||
|
|
||||||
|
export type VerifyEmailProps = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||||
|
const verificationToken = await prisma.verificationToken.findFirst({
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verificationToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the token is valid or expired
|
||||||
|
const valid = verificationToken.expires > new Date();
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
const mostRecentToken = await prisma.verificationToken.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: verificationToken.userId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there isn't a recent token or it's older than 1 hour, send a new token
|
||||||
|
if (
|
||||||
|
!mostRecentToken ||
|
||||||
|
DateTime.now().minus({ hours: 1 }).toJSDate() > mostRecentToken.createdAt
|
||||||
|
) {
|
||||||
|
await sendConfirmationToken({ email: verificationToken.user.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedUser, deletedToken] = await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: verificationToken.userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
emailVerified: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.verificationToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: verificationToken.userId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!updatedUser || !deletedToken) {
|
||||||
|
throw new Error('Something went wrong while verifying your email. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!updatedUser && !!deletedToken;
|
||||||
|
};
|
||||||
5
packages/lib/types/next-auth.d.ts
vendored
5
packages/lib/types/next-auth.d.ts
vendored
@@ -6,11 +6,11 @@ declare module 'next-auth' {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User extends Omit<DefaultUser, 'id' | 'image'> {
|
interface User extends Omit<DefaultUser, 'id' | 'image' | 'emailVerified'> {
|
||||||
id: PrismaUser['id'];
|
id: PrismaUser['id'];
|
||||||
name?: PrismaUser['name'];
|
name?: PrismaUser['name'];
|
||||||
email?: PrismaUser['email'];
|
email?: PrismaUser['email'];
|
||||||
emailVerified?: PrismaUser['emailVerified'];
|
emailVerified?: string | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ declare module 'next-auth/jwt' {
|
|||||||
id: string | number;
|
id: string | number;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
emailVerified?: string | null;
|
||||||
lastSignedIn?: string | null;
|
lastSignedIn?: string | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
@@ -7,10 +10,11 @@ import {
|
|||||||
S3Client,
|
S3Client,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import slugify from '@sindresorhus/slugify';
|
import slugify from '@sindresorhus/slugify';
|
||||||
|
import { type JWT, getToken } from 'next-auth/jwt';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { APP_BASE_URL } from '../../constants/app';
|
||||||
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
|
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
|
||||||
import { getServerComponentSession } from '../../next-auth/get-server-component-session';
|
|
||||||
import { alphaid } from '../id';
|
import { alphaid } from '../id';
|
||||||
|
|
||||||
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
||||||
@@ -18,15 +22,25 @@ export const getPresignPostUrl = async (fileName: string, contentType: string) =
|
|||||||
|
|
||||||
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
||||||
|
|
||||||
const { user } = await getServerComponentSession();
|
let token: JWT | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
token = await getToken({
|
||||||
|
req: new NextRequest(APP_BASE_URL ?? 'http://localhost:3000', {
|
||||||
|
headers: headers(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Non server-component environment
|
||||||
|
}
|
||||||
|
|
||||||
// Get the basename and extension for the file
|
// Get the basename and extension for the file
|
||||||
const { name, ext } = path.parse(fileName);
|
const { name, ext } = path.parse(fileName);
|
||||||
|
|
||||||
let key = `${alphaid(12)}/${slugify(name)}${ext}`;
|
let key = `${alphaid(12)}/${slugify(name)}${ext}`;
|
||||||
|
|
||||||
if (user) {
|
if (token) {
|
||||||
key = `${user.id}/${key}`;
|
key = `${token.id}/${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const putObjectCommand = new PutObjectCommand({
|
const putObjectCommand = new PutObjectCommand({
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VerificationToken" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
UPDATE "User"
|
||||||
|
SET "emailVerified" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "emailVerified" IS NULL;
|
||||||
@@ -36,6 +36,7 @@ model User {
|
|||||||
Document Document[]
|
Document Document[]
|
||||||
Subscription Subscription?
|
Subscription Subscription?
|
||||||
PasswordResetToken PasswordResetToken[]
|
PasswordResetToken PasswordResetToken[]
|
||||||
|
VerificationToken VerificationToken[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,16 @@ model PasswordResetToken {
|
|||||||
User User @relation(fields: [userId], references: [id])
|
User User @relation(fields: [userId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
enum SubscriptionStatus {
|
enum SubscriptionStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
PAST_DUE
|
PAST_DUE
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"@trpc/next": "^10.36.0",
|
"@trpc/next": "^10.36.0",
|
||||||
"@trpc/react-query": "^10.36.0",
|
"@trpc/react-query": "^10.36.0",
|
||||||
"@trpc/server": "^10.36.0",
|
"@trpc/server": "^10.36.0",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||||
|
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||||
|
|
||||||
import { procedure, router } from '../trpc';
|
import { procedure, router } from '../trpc';
|
||||||
import { ZSignUpMutationSchema } from './schema';
|
import { ZSignUpMutationSchema } from './schema';
|
||||||
@@ -10,7 +11,11 @@ export const authRouter = router({
|
|||||||
try {
|
try {
|
||||||
const { name, email, password, signature } = input;
|
const { name, email, password, signature } = input;
|
||||||
|
|
||||||
return await createUser({ name, email, password, signature });
|
const user = await createUser({ name, email, password, signature });
|
||||||
|
|
||||||
|
await sendConfirmationToken({ email: user.email });
|
||||||
|
|
||||||
|
return user;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let message =
|
let message =
|
||||||
'We were unable to create your account. Please review the information you provided and try again.';
|
'We were unable to create your account. Please review the information you provided and try again.';
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||||
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
||||||
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||||
|
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||||
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
||||||
|
|
||||||
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
|
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
|
ZConfirmEmailMutationSchema,
|
||||||
ZForgotPasswordFormSchema,
|
ZForgotPasswordFormSchema,
|
||||||
ZResetPasswordFormSchema,
|
ZResetPasswordFormSchema,
|
||||||
ZRetrieveUserByIdQuerySchema,
|
ZRetrieveUserByIdQuerySchema,
|
||||||
@@ -110,4 +112,25 @@ export const profileRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
sendConfirmationEmail: procedure
|
||||||
|
.input(ZConfirmEmailMutationSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const { email } = input;
|
||||||
|
|
||||||
|
return sendConfirmationToken({ email });
|
||||||
|
} catch (err) {
|
||||||
|
let message = 'We were unable to send a confirmation email. Please try again.';
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
message = err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ export const ZResetPasswordFormSchema = z.object({
|
|||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ZConfirmEmailMutationSchema = z.object({
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
||||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||||
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||||
|
export type TConfirmEmailMutationSchema = z.infer<typeof ZConfirmEmailMutationSchema>;
|
||||||
|
|||||||
@@ -4,18 +4,17 @@ import { documentRouter } from './document-router/router';
|
|||||||
import { fieldRouter } from './field-router/router';
|
import { fieldRouter } from './field-router/router';
|
||||||
import { profileRouter } from './profile-router/router';
|
import { profileRouter } from './profile-router/router';
|
||||||
import { shareLinkRouter } from './share-link-router/router';
|
import { shareLinkRouter } from './share-link-router/router';
|
||||||
import { procedure, router } from './trpc';
|
import { singleplayerRouter } from './singleplayer-router/router';
|
||||||
|
import { router } from './trpc';
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
health: procedure.query(() => {
|
|
||||||
return { status: 'ok' };
|
|
||||||
}),
|
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
profile: profileRouter,
|
profile: profileRouter,
|
||||||
document: documentRouter,
|
document: documentRouter,
|
||||||
field: fieldRouter,
|
field: fieldRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
shareLink: shareLinkRouter,
|
shareLink: shareLinkRouter,
|
||||||
|
singleplayer: singleplayerRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
37
packages/trpc/server/singleplayer-router/helper.ts
Normal file
37
packages/trpc/server/singleplayer-router/helper.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { FieldType, Prisma } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type { TCreateSinglePlayerDocumentMutationSchema } from './schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the fields provided by the user to fields compatible with Prisma.
|
||||||
|
*
|
||||||
|
* Signature fields are handled separately.
|
||||||
|
*
|
||||||
|
* @param field The field passed in by the user.
|
||||||
|
* @param signer The details of the person who is signing this document.
|
||||||
|
* @returns A field compatible with Prisma.
|
||||||
|
*/
|
||||||
|
export const mapField = (
|
||||||
|
field: TCreateSinglePlayerDocumentMutationSchema['fields'][number],
|
||||||
|
signer: TCreateSinglePlayerDocumentMutationSchema['signer'],
|
||||||
|
) => {
|
||||||
|
const customText = match(field.type)
|
||||||
|
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
|
||||||
|
.with(FieldType.EMAIL, () => signer.email)
|
||||||
|
.with(FieldType.NAME, () => signer.name)
|
||||||
|
.otherwise(() => '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: field.type,
|
||||||
|
page: field.page,
|
||||||
|
positionX: new Prisma.Decimal(field.positionX),
|
||||||
|
positionY: new Prisma.Decimal(field.positionY),
|
||||||
|
width: new Prisma.Decimal(field.width),
|
||||||
|
height: new Prisma.Decimal(field.height),
|
||||||
|
customText,
|
||||||
|
inserted: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
170
packages/trpc/server/singleplayer-router/router.ts
Normal file
170
packages/trpc/server/singleplayer-router/router.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
|
||||||
|
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
|
||||||
|
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||||
|
import { alphaid } from '@documenso/lib/universal/id';
|
||||||
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import {
|
||||||
|
DocumentStatus,
|
||||||
|
FieldType,
|
||||||
|
ReadStatus,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
import { signPdf } from '@documenso/signing';
|
||||||
|
|
||||||
|
import { procedure, router } from '../trpc';
|
||||||
|
import { mapField } from './helper';
|
||||||
|
import { ZCreateSinglePlayerDocumentMutationSchema } from './schema';
|
||||||
|
|
||||||
|
export const singleplayerRouter = router({
|
||||||
|
createSinglePlayerDocument: procedure
|
||||||
|
.input(ZCreateSinglePlayerDocumentMutationSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { signer, fields, documentData, documentName } = input;
|
||||||
|
|
||||||
|
const document = await getFile({
|
||||||
|
data: documentData.data,
|
||||||
|
type: documentData.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = await PDFDocument.load(document);
|
||||||
|
const createdAt = new Date();
|
||||||
|
|
||||||
|
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
|
||||||
|
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
|
||||||
|
const typedSignature = !isBase64 ? signer.signature : null;
|
||||||
|
|
||||||
|
// Update the document with the fields inserted.
|
||||||
|
for (const field of fields) {
|
||||||
|
const isSignatureField = field.type === FieldType.SIGNATURE;
|
||||||
|
|
||||||
|
await insertFieldInPDF(doc, {
|
||||||
|
...mapField(field, signer),
|
||||||
|
Signature: isSignatureField
|
||||||
|
? {
|
||||||
|
created: createdAt,
|
||||||
|
signatureImageAsBase64,
|
||||||
|
typedSignature,
|
||||||
|
// Dummy data.
|
||||||
|
id: -1,
|
||||||
|
recipientId: -1,
|
||||||
|
fieldId: -1,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
// Dummy data.
|
||||||
|
id: -1,
|
||||||
|
documentId: -1,
|
||||||
|
recipientId: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsignedPdfBytes = await doc.save();
|
||||||
|
|
||||||
|
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
|
||||||
|
|
||||||
|
const { token } = await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const token = alphaid();
|
||||||
|
|
||||||
|
// Fetch service user who will be the owner of the document.
|
||||||
|
const serviceUser = await tx.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
email: SERVICE_USER_EMAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id: documentDataId } = await putFile({
|
||||||
|
name: `${documentName}.pdf`,
|
||||||
|
type: 'application/pdf',
|
||||||
|
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create document.
|
||||||
|
const document = await tx.document.create({
|
||||||
|
data: {
|
||||||
|
title: documentName,
|
||||||
|
status: DocumentStatus.COMPLETED,
|
||||||
|
documentDataId,
|
||||||
|
userId: serviceUser.id,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create recipient.
|
||||||
|
const recipient = await tx.recipient.create({
|
||||||
|
data: {
|
||||||
|
documentId: document.id,
|
||||||
|
name: signer.name,
|
||||||
|
email: signer.email,
|
||||||
|
token,
|
||||||
|
signedAt: createdAt,
|
||||||
|
readStatus: ReadStatus.OPENED,
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create fields and signatures.
|
||||||
|
await Promise.all(
|
||||||
|
fields.map(async (field) => {
|
||||||
|
const insertedField = await tx.field.create({
|
||||||
|
data: {
|
||||||
|
documentId: document.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
...mapField(field, signer),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||||
|
await tx.signature.create({
|
||||||
|
data: {
|
||||||
|
fieldId: insertedField.id,
|
||||||
|
signatureImageAsBase64,
|
||||||
|
typedSignature,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { document, token };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxWait: 5000,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const template = createElement(DocumentSelfSignedEmailTemplate, {
|
||||||
|
documentName: documentName,
|
||||||
|
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email to signer.
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: signer.email,
|
||||||
|
name: signer.name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: FROM_NAME,
|
||||||
|
address: FROM_ADDRESS,
|
||||||
|
},
|
||||||
|
subject: 'Document signed',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
attachments: [{ content: signedPdfBuffer, filename: documentName }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}),
|
||||||
|
});
|
||||||
30
packages/trpc/server/singleplayer-router/schema.ts
Normal file
30
packages/trpc/server/singleplayer-router/schema.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { DocumentDataType, FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const ZCreateSinglePlayerDocumentMutationSchema = z.object({
|
||||||
|
documentData: z.object({
|
||||||
|
data: z.string(),
|
||||||
|
type: z.nativeEnum(DocumentDataType),
|
||||||
|
}),
|
||||||
|
documentName: z.string(),
|
||||||
|
signer: z.object({
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
name: z.string(),
|
||||||
|
signature: z.string(),
|
||||||
|
}),
|
||||||
|
fields: z.array(
|
||||||
|
z.object({
|
||||||
|
page: z.number(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
positionX: z.number(),
|
||||||
|
positionY: z.number(),
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateSinglePlayerDocumentMutationSchema = z.infer<
|
||||||
|
typeof ZCreateSinglePlayerDocumentMutationSchema
|
||||||
|
>;
|
||||||
@@ -19,6 +19,6 @@
|
|||||||
],
|
],
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.mjs", "**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@@ -62,6 +62,7 @@ declare namespace NodeJS {
|
|||||||
VERCEL_URL?: string;
|
VERCEL_URL?: string;
|
||||||
|
|
||||||
DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
|
DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
|
||||||
|
FONT_CAVEAT_URI: string;
|
||||||
|
|
||||||
POSTGRES_URL?: string;
|
POSTGRES_URL?: string;
|
||||||
DATABASE_URL?: string;
|
DATABASE_URL?: string;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes, useState } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Download } from 'lucide-react';
|
import { Download } from 'lucide-react';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { DocumentData } from '@documenso/prisma/client';
|
import type { 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';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -41,9 +42,10 @@ export const DocumentDownloadButton = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const link = window.document.createElement('a');
|
const link = window.document.createElement('a');
|
||||||
|
const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName;
|
||||||
|
|
||||||
link.href = window.URL.createObjectURL(blob);
|
link.href = window.URL.createObjectURL(blob);
|
||||||
link.download = fileName || 'document.pdf';
|
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||||
|
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.0.3",
|
"@radix-ui/react-tabs": "^1.0.3",
|
||||||
"@radix-ui/react-toast": "^1.1.3",
|
"@radix-ui/react-toast": "^1.1.3",
|
||||||
"@radix-ui/react-toggle": "^1.0.2",
|
"@radix-ui/react-toggle": "^1.0.2",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.6",
|
"@radix-ui/react-tooltip": "^1.0.6",
|
||||||
"@tanstack/react-table": "^8.9.1",
|
"@tanstack/react-table": "^8.9.1",
|
||||||
"class-variance-authority": "^0.6.0",
|
"class-variance-authority": "^0.6.0",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZAddSignatureFormSchema = z.object({
|
export const ZAddSignatureFormSchema = z.object({
|
||||||
email: z.string().min(1).email(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: 'Email is required' })
|
||||||
|
.email({ message: 'Invalid email address' }),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
signature: z.string(),
|
signature: z.string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const ZAddSignersFormSchema = z
|
|||||||
z.object({
|
z.object({
|
||||||
formId: z.string().min(1),
|
formId: z.string().min(1),
|
||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
email: z.string().min(1).email(),
|
email: z.string().email().min(1),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
54
packages/ui/primitives/theme-switcher.tsx
Normal file
54
packages/ui/primitives/theme-switcher.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Monitor, MoonStar, Sun } from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
|
||||||
|
export const ThemeSwitcher = () => {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-muted flex items-center gap-x-1 rounded-full p-1">
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground relative z-10 flex h-8 w-8 items-center justify-center rounded-full"
|
||||||
|
onClick={() => setTheme('light')}
|
||||||
|
>
|
||||||
|
{isMounted && theme === 'light' && (
|
||||||
|
<motion.div
|
||||||
|
className="bg-background absolute inset-0 rounded-full mix-blend-exclusion"
|
||||||
|
layoutId="selected-theme"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground relative z-10 flex h-8 w-8 items-center justify-center rounded-full"
|
||||||
|
onClick={() => setTheme('dark')}
|
||||||
|
>
|
||||||
|
{isMounted && theme === 'dark' && (
|
||||||
|
<motion.div
|
||||||
|
className="bg-background absolute inset-0 rounded-full mix-blend-exclusion"
|
||||||
|
layoutId="selected-theme"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MoonStar className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground relative z-10 flex h-8 w-8 items-center justify-center rounded-full"
|
||||||
|
onClick={() => setTheme('system')}
|
||||||
|
>
|
||||||
|
{isMounted && theme === 'system' && (
|
||||||
|
<motion.div
|
||||||
|
className="bg-background absolute inset-0 rounded-full mix-blend-exclusion"
|
||||||
|
layoutId="selected-theme"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Monitor className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -85,6 +85,7 @@
|
|||||||
"VERCEL_ENV",
|
"VERCEL_ENV",
|
||||||
"VERCEL_URL",
|
"VERCEL_URL",
|
||||||
"DEPLOYMENT_TARGET",
|
"DEPLOYMENT_TARGET",
|
||||||
|
"FONT_CAVEAT_URI",
|
||||||
"POSTGRES_URL",
|
"POSTGRES_URL",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"POSTGRES_PRISMA_URL",
|
"POSTGRES_PRISMA_URL",
|
||||||
|
|||||||
Reference in New Issue
Block a user