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
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
build_app:
|
||||
name: Build App
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -37,3 +37,16 @@ jobs:
|
||||
|
||||
- name: 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)
|
||||
|
||||
### 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 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 */
|
||||
const path = require('path');
|
||||
const { withContentlayer } = require('next-contentlayer');
|
||||
import dotenv from 'dotenv';
|
||||
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'}`];
|
||||
|
||||
ENV_FILES.forEach((file) => {
|
||||
require('dotenv').config({
|
||||
dotenv.config({
|
||||
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} */
|
||||
const config = {
|
||||
experimental: {
|
||||
@@ -17,9 +31,16 @@ const config = {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
||||
transpilePackages: [
|
||||
'@documenso/assets',
|
||||
'@documenso/lib',
|
||||
'@documenso/tailwind-config',
|
||||
'@documenso/trpc',
|
||||
'@documenso/ui',
|
||||
],
|
||||
env: {
|
||||
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
@@ -34,7 +55,7 @@ const config = {
|
||||
|
||||
return config;
|
||||
},
|
||||
async headers() {
|
||||
headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
@@ -68,7 +89,7 @@ const config = {
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
rewrites() {
|
||||
return [
|
||||
{
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/assets": "*",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
@@ -23,6 +24,7 @@
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"million": "^2.6.4",
|
||||
"next": "14.0.0",
|
||||
"next-auth": "4.24.3",
|
||||
"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 { base64 } from '@documenso/lib/universal/base64';
|
||||
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 { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
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 { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} 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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
|
||||
|
||||
type SinglePlayerModeStep = 'fields' | 'sign';
|
||||
|
||||
// !: 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 [fields, setFields] = useState<Field[]>([]);
|
||||
|
||||
const { mutateAsync: createSinglePlayerDocument } =
|
||||
trpc.singleplayer.createSinglePlayerDocument.useMutation();
|
||||
|
||||
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
|
||||
fields: {
|
||||
title: 'Add document',
|
||||
|
||||
@@ -5,14 +5,13 @@ import type { HTMLAttributes } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { FaXTwitter } from 'react-icons/fa6';
|
||||
import { LiaDiscord } from 'react-icons/lia';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
@@ -36,8 +35,6 @@ const FOOTER_LINKS = [
|
||||
];
|
||||
|
||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -79,21 +76,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
))}
|
||||
</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">
|
||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
||||
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
|
||||
<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 className="flex flex-wrap">
|
||||
<ThemeSwitcher />
|
||||
</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 { appRouter } from '@documenso/trpc/server/router';
|
||||
|
||||
export const config = {
|
||||
maxDuration: 60,
|
||||
};
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.mjs",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
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} */
|
||||
const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
@@ -19,15 +26,18 @@ const config = {
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: [
|
||||
'@documenso/assets',
|
||||
'@documenso/ee',
|
||||
'@documenso/lib',
|
||||
'@documenso/prisma',
|
||||
'@documenso/tailwind-config',
|
||||
'@documenso/trpc',
|
||||
'@documenso/ui',
|
||||
'@documenso/email',
|
||||
],
|
||||
env: {
|
||||
APP_VERSION: version,
|
||||
NEXT_PUBLIC_PROJECT: 'web',
|
||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||
},
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/assets": "*",
|
||||
"@documenso/ee": "*",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { History } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
@@ -54,11 +55,14 @@ export const ResendDocumentActionItem = ({
|
||||
document,
|
||||
recipients,
|
||||
}: ResendDocumentActionItemProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isOwner = document.userId === session?.user?.id;
|
||||
|
||||
const isDisabled =
|
||||
!isOwner ||
|
||||
document.status !== 'PENDING' ||
|
||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||
|
||||
|
||||
@@ -62,9 +62,10 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
});
|
||||
|
||||
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.download = row.title || 'document.pdf';
|
||||
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
|
||||
@@ -88,9 +88,10 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
});
|
||||
|
||||
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.download = row.title || 'document.pdf';
|
||||
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Loader } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
@@ -22,6 +23,7 @@ export type UploadDocumentProps = {
|
||||
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -79,7 +81,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone
|
||||
className="min-h-[40vh]"
|
||||
disabled={remaining.documents === 0}
|
||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||
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 { 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 { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
@@ -30,6 +31,7 @@ export default async function AuthenticatedDashboardLayout({
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
<Header user={user} />
|
||||
|
||||
<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 { ThemeProvider } from '~/providers/next-theme';
|
||||
import { PlausibleProvider } from '~/providers/plausible';
|
||||
import { PostHogPageview } from '~/providers/posthog';
|
||||
|
||||
import './globals.css';
|
||||
@@ -69,13 +68,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<body>
|
||||
<LocaleProvider locale={locale}>
|
||||
<FeatureFlagProvider initialFlags={flags}>
|
||||
<PlausibleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PlausibleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<TooltipProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
<Toaster />
|
||||
</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 { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
import { StackAvatars } from './stack-avatars';
|
||||
|
||||
@@ -85,15 +86,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Opened</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
</div>
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -102,15 +95,7 @@ export const StackAvatarsWithTooltip = ({
|
||||
<div>
|
||||
<h1 className="text-base font-medium">Uncompleted</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
</div>
|
||||
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
@@ -14,6 +15,14 @@ export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
// const pathname = usePathname();
|
||||
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 (
|
||||
<div
|
||||
@@ -33,8 +42,8 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 font-mono text-xs">
|
||||
Ctrl+K
|
||||
<div className="text-muted-foreground bg-muted flex items-center rounded-md px-1.5 py-0.5 text-xs tracking-wider">
|
||||
{modifierKey}+K
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
.object({
|
||||
currentPassword: z.string().min(6).max(72),
|
||||
password: z.string().min(6).max(72),
|
||||
repeatedPassword: z.string().min(6).max(72),
|
||||
currentPassword: z
|
||||
.string()
|
||||
.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, {
|
||||
message: 'Passwords do not match',
|
||||
|
||||
@@ -28,7 +28,7 @@ const LOGIN_REDIRECT_PATH = '/documents';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
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>;
|
||||
|
||||
@@ -21,7 +21,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
export const ZSignUpFormSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
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' }),
|
||||
});
|
||||
|
||||
@@ -134,6 +137,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</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 { appRouter } from '@documenso/trpc/server/router';
|
||||
|
||||
export const config = {
|
||||
maxDuration: 60,
|
||||
};
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
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:web": "turbo run build --filter=@documenso/web",
|
||||
"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:fix": "turbo run lint:fix",
|
||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||
@@ -47,7 +47,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"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({
|
||||
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,
|
||||
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
|
||||
@@ -17,16 +17,20 @@
|
||||
"worker:test": "tsup worker/index.ts --format esm"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/nodemailer-resend": "1.0.0",
|
||||
"@react-email/components": "^0.0.7",
|
||||
"@documenso/nodemailer-resend": "2.0.0",
|
||||
"@react-email/components": "^0.0.11",
|
||||
"@react-email/tailwind": "0.0.9",
|
||||
"nodemailer": "^6.9.3",
|
||||
"react-email": "^1.9.4",
|
||||
"resend": "^1.1.0"
|
||||
"react-email": "^1.9.5",
|
||||
"resend": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^6.4.8",
|
||||
"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 { compare } from 'bcrypt';
|
||||
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 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';
|
||||
|
||||
@@ -48,6 +51,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
id: Number(user.id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
emailVerified: user.emailVerified?.toISOString() ?? null,
|
||||
} satisfies User;
|
||||
},
|
||||
}),
|
||||
@@ -61,6 +65,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
id: Number(profile.sub),
|
||||
name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(),
|
||||
email: profile.email,
|
||||
emailVerified: profile.email_verified ? new Date().toISOString() : null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -70,9 +75,10 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
const merged = {
|
||||
...token,
|
||||
...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 retrieved = await prisma.user.findFirst({
|
||||
@@ -88,6 +94,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
merged.id = retrieved.id;
|
||||
merged.name = retrieved.name;
|
||||
merged.email = retrieved.email;
|
||||
merged.emailVerified = retrieved.emailVerified?.toISOString() ?? null;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -97,7 +104,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
) {
|
||||
merged.lastSignedIn = new Date().toISOString();
|
||||
|
||||
await prisma.user.update({
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: Number(merged.id),
|
||||
},
|
||||
@@ -105,6 +112,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
lastSignedIn: merged.lastSignedIn,
|
||||
},
|
||||
});
|
||||
|
||||
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -112,7 +121,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
name: merged.name,
|
||||
email: merged.email,
|
||||
lastSignedIn: merged.lastSignedIn,
|
||||
};
|
||||
emailVerified: merged.emailVerified,
|
||||
} satisfies JWT;
|
||||
},
|
||||
|
||||
session({ token, session }) {
|
||||
@@ -123,6 +133,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
id: Number(token.id),
|
||||
name: token.name,
|
||||
email: token.email,
|
||||
emailVerified: token.emailVerified ?? null,
|
||||
},
|
||||
} satisfies Session;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@aws-sdk/cloudfront-signer": "^3.410.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
||||
"@documenso/assets": "*",
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@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 { 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 { FindResultSet } from '../../types/find-result-set';
|
||||
import type { FindResultSet } from '../../types/find-result-set';
|
||||
|
||||
export interface FindDocumentsOptions {
|
||||
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 {
|
||||
data: maskedData,
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
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 {
|
||||
CAVEAT_FONT_PATH,
|
||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||
DEFAULT_STANDARD_FONT_SIZE,
|
||||
MIN_HANDWRITING_FONT_SIZE,
|
||||
@@ -10,12 +9,12 @@ import {
|
||||
} from '@documenso/lib/constants/pdf';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
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) => {
|
||||
// Fetch the font file from the public URL.
|
||||
const fontResponse = await fetch(CAVEAT_FONT_PATH);
|
||||
const fontCaveat = await fontResponse.arrayBuffer();
|
||||
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface User extends Omit<DefaultUser, 'id' | 'image'> {
|
||||
interface User extends Omit<DefaultUser, 'id' | 'image' | 'emailVerified'> {
|
||||
id: PrismaUser['id'];
|
||||
name?: PrismaUser['name'];
|
||||
email?: PrismaUser['email'];
|
||||
emailVerified?: PrismaUser['emailVerified'];
|
||||
emailVerified?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ declare module 'next-auth/jwt' {
|
||||
id: string | number;
|
||||
name?: string | null;
|
||||
email: string | null;
|
||||
emailVerified?: string | null;
|
||||
lastSignedIn?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
@@ -7,10 +10,11 @@ import {
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import { type JWT, getToken } from 'next-auth/jwt';
|
||||
import path from 'node:path';
|
||||
|
||||
import { APP_BASE_URL } from '../../constants/app';
|
||||
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
|
||||
import { getServerComponentSession } from '../../next-auth/get-server-component-session';
|
||||
import { alphaid } from '../id';
|
||||
|
||||
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 { 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
|
||||
const { name, ext } = path.parse(fileName);
|
||||
|
||||
let key = `${alphaid(12)}/${slugify(name)}${ext}`;
|
||||
|
||||
if (user) {
|
||||
key = `${user.id}/${key}`;
|
||||
if (token) {
|
||||
key = `${token.id}/${key}`;
|
||||
}
|
||||
|
||||
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,7 +36,8 @@ model User {
|
||||
Document Document[]
|
||||
Subscription Subscription?
|
||||
PasswordResetToken PasswordResetToken[]
|
||||
|
||||
VerificationToken VerificationToken[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
@@ -49,6 +50,16 @@ model PasswordResetToken {
|
||||
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 {
|
||||
ACTIVE
|
||||
PAST_DUE
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"@trpc/next": "^10.36.0",
|
||||
"@trpc/react-query": "^10.36.0",
|
||||
"@trpc/server": "^10.36.0",
|
||||
"luxon": "^3.4.0",
|
||||
"superjson": "^1.13.1",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
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 { ZSignUpMutationSchema } from './schema';
|
||||
@@ -10,7 +11,11 @@ export const authRouter = router({
|
||||
try {
|
||||
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) {
|
||||
let message =
|
||||
'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 { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
||||
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 { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
||||
|
||||
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZConfirmEmailMutationSchema,
|
||||
ZForgotPasswordFormSchema,
|
||||
ZResetPasswordFormSchema,
|
||||
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),
|
||||
});
|
||||
|
||||
export const ZConfirmEmailMutationSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
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 { profileRouter } from './profile-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({
|
||||
health: procedure.query(() => {
|
||||
return { status: 'ok' };
|
||||
}),
|
||||
auth: authRouter,
|
||||
profile: profileRouter,
|
||||
document: documentRouter,
|
||||
field: fieldRouter,
|
||||
admin: adminRouter,
|
||||
shareLink: shareLinkRouter,
|
||||
singleplayer: singleplayerRouter,
|
||||
});
|
||||
|
||||
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
|
||||
},
|
||||
"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"]
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
|
||||
FONT_CAVEAT_URI: string;
|
||||
|
||||
POSTGRES_URL?: string;
|
||||
DATABASE_URL?: string;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -41,9 +42,10 @@ export const DocumentDownloadButton = ({
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName;
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = fileName || 'document.pdf';
|
||||
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@radix-ui/react-toast": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.0.2",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@tanstack/react-table": "^8.9.1",
|
||||
"class-variance-authority": "^0.6.0",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
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(),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export const ZAddSignersFormSchema = z
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
email: z.string().email().min(1),
|
||||
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_URL",
|
||||
"DEPLOYMENT_TARGET",
|
||||
"FONT_CAVEAT_URI",
|
||||
"POSTGRES_URL",
|
||||
"DATABASE_URL",
|
||||
"POSTGRES_PRISMA_URL",
|
||||
|
||||
Reference in New Issue
Block a user