Compare commits

..

2 Commits

Author SHA1 Message Date
3eebd52dd1 fix: improve types 2023-04-19 23:52:37 +10:00
f8f4c07d11 add toast error for invalid email 2023-04-19 23:48:21 +10:00
75 changed files with 5207 additions and 4654 deletions

View File

@ -37,13 +37,6 @@ SMTP_MAIL_PASSWORD=''
# Sender for signing requests and completion mails.
MAIL_FROM='documenso@localhost.com'
# STRIPE
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
#FEATURE FLAGS
# Allow users to register via the /signup page. Otherwise they will be redirect to the home page.
NEXT_PUBLIC_ALLOW_SIGNUP=true
NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS=true
ALLOW_SIGNUP=true

View File

@ -13,7 +13,7 @@
"editor.codeActionsOnSave": {
"source.removeUnusedImports": false
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.tsdk": "node_modules\\typescript\\lib",
"spellright.language": ["de"],
"spellright.documentTypes": ["markdown", "latex", "plaintext"]
}

View File

@ -1,9 +1,6 @@
> We are launching on Product Hunt soon! Sign up to support the launch:
> <center><a href="https://dub.sh/documenso-launch"><img src="https://img.shields.io/badge/Documenso%20on%20Product%20Hunt-Notify%20Me-orange" alt="Product Hunt"></a></center>
<p align="center" style="margin-top: 12px">
<a href="https://github.com/documenso/documenso.com">
<img width="250px" src="https://github.com/documenso/documenso/assets/1309312/cd7823ec-4baa-40b9-be78-4acb3b1c73cb" alt="Documenso Logo">
<img width="250px" src="https://user-images.githubusercontent.com/1309312/224986248-5b8a5cdc-2dc1-46b9-a354-985bb6808ee0.png" alt="Documenso Logo">
</a>
<h3 align="center">Open Source Signing Infrastructure</h3>
@ -74,14 +71,14 @@ The current project goal is to <b>[release a production ready version](https://g
- To contribute please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
## Tools
# Tech
Documenso is built using awesome open source tech including:
- [Typescript](https://www.typescriptlang.org/)
- [Javascript (when necessary)](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
- [Javascript (when neccessary)](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
- [NextJS (JS Fullstack Framework)](https://nextjs.org/)
- [Postgres SQL (Database)](https://www.postgresql.org/)
- [Prisma (ORM - Object-relational mapping)](https://www.prisma.io/)
@ -89,7 +86,7 @@ Documenso is built using awesome open source tech including:
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
- Check out `/package.json` and `/apps/web/package.json` for more
- Check out /packages.json and /apps/web/package.json for more
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
# Getting Started
@ -99,7 +96,7 @@ Documenso is built using awesome open source tech including:
To run Documenso locally you need
- [Node.js (Version: >=18.x)](https://nodejs.org/en/download/)
- Node Package Manager NPM - included in Node.js
- Node Package Manger NPM - included in Node.js
- [PostgreSQL (local or remote)](https://www.postgresql.org/download/)
## Developer Quickstart
@ -114,7 +111,7 @@ Want to get up and running quickly? Follow these steps:
git clone https://github.com/documenso/documenso
```
- Set up your `.env` file using the recommendations in the `.env.example` file.
- Set up your .env file using the recommendations in the .env.example file.
- Run `npm run dx` in the root directory
- This will spin up a postgres database and inbucket mail server in docker containers.
- Run `npm run dev` in the root directory
@ -127,11 +124,11 @@ That's it! You should now be able to access the app at http://localhost:3000
Incoming mail will be available at http://localhost:9000
Your database will also be available on port `54320`. You can connect to it using your favorite database client.
Your database will also be available on port `5432`. You can connect to it using your favorite database client.
## Developer Setup
Follow these steps to setup documenso on you local machine:
Follow these steps to setup documenso on you local machnine:
- [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
```sh
@ -141,36 +138,36 @@ Follow these steps to setup documenso on you local machine:
- Rename <code>.env.example</code> to <code>.env</code>
- Set DATABASE_URL value in .env file
- You can use the provided test database url (may be wiped at any point)
- Or setup a local postgres sql instance (recommended)
- Or setup a local postgres sql instance (recommened)
- Create the database scheme by running <code>db-migrate:dev</code>
- Setup your mail provider
- Set <code>SENDGRID_API_KEY</code> value in .env file
- You need a SendGrid account, which you can create [here](https://signup.sendgrid.com/).
- Documenso uses [Nodemailer](https://nodemailer.com/about/) so you can easily use your own SMTP server by setting the <code>SMTP\_\* variables</code> in your .env
- Documenso uses [Nodemailer](https://nodemailer.com/about/) so you can easily use your own SMTP server by setting the <code>SMTP\_\* varibles</code> in your .env
- Run <code>npm run dev</code> root directory to start
- Register a new user at http://localhost:3000/signup
---
- Optional: Seed the database using <code>npm run db-seed</code> to create a test user and document
- Optional: Upload and sign <code>apps/web/ressources/example.pdf</code> manually to test your setup
- Optional: Upload and sign <code>apps\web\ressources\example.pdf</code> manually to test your setup
- Optional: Create your own signing certificate
- A demo certificate is provided in `/app/web/ressources/certificate.p12`
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL) see **[Create your own signing certificate](#creating-your-own-signing-certificate)**.
- A demo certificate is provided in /app/web/ressources/certificate.p12
- To generate you own using these steps and a linux Terminal or Windows Linux Subsystem see **Create your own signging certificate**.
## Updating
- If you pull the newest version from main, using <code>git pull</code>, it may be necessary to regenerate your database client
- You can do this by running the generate command in `/packages/prisma`:
- If you pull the newest version from main, using <code>git pull</code>, it may be neccessary to regenerate your database client
- You can do this by running the generate command in /packages/prisma:
```sh
npx prisma generate
```
- This is not necessary on first clone.
- This is not neccessary on first clone
# Creating your own signing certificate
# Creating your own signging certificate
For the digital signature of your documents you need a signing certificate in .p12 format (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one:
For the digital signature of you documents you need a signign certificate in .p12 formate (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one:
1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:\
<code>openssl genrsa -out private.key 2048</code>

4
apps/web/.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": ["next/babel"],
"plugins": []
}

View File

@ -1,8 +1,3 @@
{
"extends": [
"next/core-web-vitals"
],
"rules": {
"react/no-unescaped-entities": "off"
}
}
"extends": ["next/babel", "next/core-web-vitals"]
}

View File

@ -1,70 +0,0 @@
import { useState } from "react";
import { classNames } from "@documenso/lib";
import { STRIPE_PLANS, fetchCheckoutSession, useSubscription } from "@documenso/lib/stripe";
import { Button } from "@documenso/ui";
import { Switch } from "@headlessui/react";
export const BillingPlans = () => {
const { subscription, isLoading } = useSubscription();
const [isAnnual, setIsAnnual] = useState(false);
return (
<div>
{!subscription &&
STRIPE_PLANS.map((plan) => (
<div key={plan.name} className="rounded-lg border py-4 px-6">
<h3 className="text-center text-lg font-medium leading-6 text-gray-900">{plan.name}</h3>
<div className="my-4 flex justify-center">
<Switch.Group as="div" className="flex items-center">
<Switch
checked={isAnnual}
onChange={setIsAnnual}
className={classNames(
isAnnual ? "bg-neon-600" : "bg-gray-200",
"focus:ring-neon-600 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2"
)}>
<span
aria-hidden="true"
className={classNames(
isAnnual ? "translate-x-5" : "translate-x-0",
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3 text-sm">
<span className="font-medium text-gray-900">Annual billing</span>{" "}
<span className="text-gray-500">(Save $60)</span>
</Switch.Label>
</Switch.Group>
</div>
<p className="mt-2 text-center text-gray-500">
${(isAnnual ? plan.prices.yearly.price : plan.prices.monthly.price).toFixed(2)}{" "}
<span className="text-sm text-gray-400">{isAnnual ? "/yr" : "/mo"}</span>
</p>
<p className="mt-4 text-center text-sm text-gray-500">
All you need for easy signing. <br></br>Includes everthing we build this year.
</p>
<div className="mt-4">
<Button
className="w-full"
disabled={isLoading}
onClick={() =>
fetchCheckoutSession({
priceId: isAnnual ? plan.prices.yearly.priceId : plan.prices.monthly.priceId,
}).then((res) => {
if (res.success) {
window.location.href = res.url;
}
})
}>
Subscribe
</Button>
</div>
</div>
))}
</div>
);
};

View File

@ -1,51 +0,0 @@
import { useSubscription } from "@documenso/lib/stripe"
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import { SubscriptionStatus } from '@prisma/client'
import Link from "next/link";
export const BillingWarning = () => {
const { subscription } = useSubscription();
return (
<>
{subscription?.status === SubscriptionStatus.PAST_DUE && (
<div className="bg-yellow-50 p-4 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-3xl items-start justify-center">
<div className="flex-shrink-0">
<PaperAirplaneIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
Your subscription is past due.{" "}
<Link href="/account/billing" className="text-yellow-700 underline">
Please update your payment information to avoid any service interruptions.
</Link>
</p>
</div>
</div>
</div>
)}
{subscription?.status === SubscriptionStatus.INACTIVE && (
<div className="bg-red-50 p-4 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-3xl items-center justify-center">
<div className="flex-shrink-0">
<PaperAirplaneIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">
Your subscription is inactive. You can continue to view and edit your documents,
but you will not be able to send them or create new ones.{" "}
<Link href="/account/billing" className="text-red-700 underline">
You can update your payment information here
</Link>
</p>
</div>
</div>
</div>
)}
</>
)
}

View File

@ -8,17 +8,10 @@ const stc = require("string-to-color");
export default function FieldTypeSelector(props: any) {
const fieldTypes = [
{
id: FieldType.SIGNATURE,
name: "Signature",
id: FieldType.SIGNATURE,
},
{
id: FieldType.NAME,
name: "Name",
},
{
id: FieldType.DATE,
name: "Date",
},
{ name: "Date", id: FieldType.DATE },
];
const [selectedFieldType, setSelectedFieldType] = useState(fieldTypes[0].id);

View File

@ -1,95 +0,0 @@
import { Fragment, useEffect, useState } from "react";
import { classNames, localStorage } from "@documenso/lib";
import { Button } from "@documenso/ui";
import { Dialog, Transition } from "@headlessui/react";
export default function NameDialog(props: any) {
const [name, setName] = useState(props.defaultName);
useEffect(() => {
const nameFromStorage = localStorage.getItem("typedName");
if (nameFromStorage) {
setName(nameFromStorage);
}
}, []);
return (
<Transition.Root show={props.open} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
onClose={() => {
props.setOpen(false);
}}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative min-h-[350px] transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
<div>
<h4 className="text-center text-2xl font-medium">
Enter your name in the input below!
</h4>
<div className="my-3 border-b border-gray-300">
<input
value={name}
onChange={(e) => {
setName(e.target.value);
}}
className={classNames(
"focus:border-neon focus:ring-neon mt-14 block h-10 w-full text-center align-bottom font-sans text-2xl leading-none"
)}
placeholder="Kindly type your name"
/>
</div>
<div className="flex flex-row-reverse items-center gap-x-4">
<Button
color="secondary"
onClick={() => {
props.onClose();
props.setOpen(false);
}}>
Cancel
</Button>
<Button
className="ml-3"
disabled={!name}
onClick={() => {
localStorage.setItem("typedName", name);
props.onClose({
type: "type",
typedSignature: name,
});
}}>
Sign
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { createField } from "@documenso/features/editor";
@ -6,7 +6,6 @@ import { createOrUpdateField, deleteField, signDocument } from "@documenso/lib/a
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
import { Button } from "@documenso/ui";
import Logo from "../logo";
import NameDialog from "./name-dialog";
import SignatureDialog from "./signature-dialog";
import { CheckBadgeIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
import { FieldType } from "@prisma/client";
@ -17,62 +16,21 @@ const PDFViewer = dynamic(() => import("./pdf-viewer"), {
export default function PDFSigner(props: any) {
const router = useRouter();
const [signatureDialogOpen, setSignatureDialogOpen] = useState(false);
const [nameDialogOpen, setNameDialogOpen] = useState(false);
const [open, setOpen] = useState(false);
const [signingDone, setSigningDone] = useState(false);
const [localSignatures, setLocalSignatures] = useState<any[]>([]);
const [fields, setFields] = useState<any[]>(props.fields);
const signatureFields = useMemo(
() => fields.filter((field) => [FieldType.SIGNATURE].includes(field.type)),
[fields]
);
const signatureFields = fields.filter((field) => field.type === FieldType.SIGNATURE);
const [dialogField, setDialogField] = useState<any>();
function signField(options: {
fieldId: string;
type: string;
typedSignature?: string;
signatureImage?: string;
}) {
const { fieldId, type, typedSignature, signatureImage } = options;
const signature = {
fieldId,
type,
typedSignature,
signatureImage,
};
const field = fields.find((e) => e.id == fieldId);
if (!field) {
return;
}
setLocalSignatures((s) => [...s.filter((e) => e.fieldId !== fieldId), signature]);
setFields((prevState) => {
const newState = [...prevState];
const index = newState.findIndex((e) => e.id == fieldId);
newState[index] = {
...newState[index],
signature,
};
return newState;
});
}
useEffect(() => {
setSigningDone(checkIfSigningIsDone());
}, [fields]);
function onClick(item: any) {
if (item.type === FieldType.SIGNATURE) {
if (item.type === "SIGNATURE") {
setDialogField(item);
setSignatureDialogOpen(true);
}
if (item.type === FieldType.NAME) {
setDialogField(item);
setNameDialogOpen(true);
setOpen(true);
}
}
@ -85,107 +43,31 @@ export default function PDFSigner(props: any) {
if (!dialogResult) return;
signField({
const signature = {
fieldId: dialogField.id,
type: dialogResult.type,
typedSignature: dialogResult.typedSignature,
signatureImage: dialogResult.signatureImage,
});
};
setSignatureDialogOpen(false);
setNameDialogOpen(false);
setLocalSignatures(localSignatures.concat(signature));
fields.splice(
fields.findIndex(function (i) {
return i.id === signature.fieldId;
}),
1
);
const signedField = { ...dialogField };
signedField.signature = signature;
setFields((prevState) => [...prevState, signedField]);
setOpen(false);
setDialogField(null);
}
function checkIfSigningIsDone(): boolean {
// Check if all fields are signed..
if (signatureFields.length > 0) {
// If there are no fields to sign at least one signature is enough
return fields
.filter((field) => field.type === FieldType.SIGNATURE)
.every((field) => field.signature);
} else {
// If we don't have a signature field, we need at least one free signature
// to be able to complete signing
const freeSignatureFields = fields.filter((field) => field.type === FieldType.FREE_SIGNATURE);
return freeSignatureFields.length > 0 && freeSignatureFields.every((field) => field.signature);
}
}
function addFreeSignature(e: any, page: number, recipient: any): any {
const freeSignatureField = createField(e, page, recipient, FieldType.FREE_SIGNATURE);
createOrUpdateField(props.document, freeSignatureField, recipient.token).then((res) => {
setFields((prevState) => [...prevState, res]);
setDialogField(res);
setSignatureDialogOpen(true);
});
return freeSignatureField;
}
function onDeleteHandler(id: any) {
const field = fields.find((e) => e.id == id);
const fieldIndex = fields.map((item) => item.id).indexOf(id);
if (fieldIndex > -1) {
const fieldWithoutRemoved = [...fields];
const removedField = fieldWithoutRemoved.splice(fieldIndex, 1);
setFields(fieldWithoutRemoved);
const signaturesWithoutRemoved = [...localSignatures];
const removedSignature = signaturesWithoutRemoved.splice(
signaturesWithoutRemoved.findIndex(function (i) {
return i.fieldId === id;
}),
1
);
setLocalSignatures(signaturesWithoutRemoved);
deleteField(field).catch((err) => {
setFields(fieldWithoutRemoved.concat(removedField));
setLocalSignatures(signaturesWithoutRemoved.concat(removedSignature));
});
}
}
useEffect(() => {
setSigningDone(checkIfSigningIsDone());
}, [fields]);
useEffect(() => {
const nameFields = fields.filter((field) => field.type === FieldType.NAME);
if (nameFields.length > 0) {
nameFields.forEach((field) => {
if (!field.signature && props.recipient?.name) {
signField({
fieldId: field.id,
type: "type",
typedSignature: props.recipient.name,
});
}
});
}
// We are intentionally not specifying deps here
// because we want to run this effect on the initial render
}, []);
return (
<>
<SignatureDialog
open={signatureDialogOpen}
setOpen={setSignatureDialogOpen}
onClose={onDialogClose}
/>
<NameDialog
open={nameDialogOpen}
setOpen={setNameDialogOpen}
onClose={onDialogClose}
defaultName={props.recipient?.name ?? ""}
/>
<SignatureDialog open={open} setOpen={setOpen} onClose={onDialogClose} />
<div className="bg-neon p-4">
<div className="flex">
<div className="flex-shrink-0">
@ -198,11 +80,12 @@ export default function PDFSigner(props: any) {
: props.document.User.email}{" "}
would like you to sign this document.
</p>
<p className="mt-3 text-sm md:mt-0 md:ml-6 text-right md:text-inherit">
<p className="mt-3 text-sm md:mt-0 md:ml-6">
<Button
disabled={!signingDone}
color="secondary"
icon={CheckBadgeIcon}
className="float-right"
onClick={() => {
signDocument(props.document, localSignatures, `${router.query.token}`).then(
() => {
@ -251,4 +134,52 @@ export default function PDFSigner(props: any) {
onDelete={onDeleteHandler}></PDFViewer>
</>
);
function checkIfSigningIsDone(): boolean {
// Check if all fields are signed..
if (signatureFields.length > 0) {
// If there are no fields to sign at least one signature is enough
return fields
.filter((field) => field.type === FieldType.SIGNATURE)
.every((field) => field.signature);
} else {
return localSignatures.length > 0;
}
}
function addFreeSignature(e: any, page: number, recipient: any): any {
const freeSignatureField = createField(e, page, recipient, FieldType.FREE_SIGNATURE);
createOrUpdateField(props.document, freeSignatureField, recipient.token).then((res) => {
setFields((prevState) => [...prevState, res]);
setDialogField(res);
setOpen(true);
});
return freeSignatureField;
}
function onDeleteHandler(id: any) {
const field = fields.find((e) => e.id == id);
const fieldIndex = fields.map((item) => item.id).indexOf(id);
if (fieldIndex > -1) {
const fieldWithoutRemoved = [...fields];
const removedField = fieldWithoutRemoved.splice(fieldIndex, 1);
setFields(fieldWithoutRemoved);
const signaturesWithoutRemoved = [...localSignatures];
const removedSignature = signaturesWithoutRemoved.splice(
signaturesWithoutRemoved.findIndex(function (i) {
return i.fieldId === id;
}),
1
);
setLocalSignatures(signaturesWithoutRemoved);
deleteField(field).catch((err) => {
setFields(fieldWithoutRemoved.concat(removedField));
setLocalSignatures(signaturesWithoutRemoved.concat(removedSignature));
});
}
}
}

View File

@ -45,11 +45,10 @@ export default function RecipientSelector(props: any) {
{props?.recipients.map((recipient: any) => (
<Listbox.Option
key={recipient?.id}
disabled={!recipient?.email}
className={({ active }) =>
classNames(
active ? "bg-neon-dark text-white" : "text-gray-900",
"relative cursor-default select-none py-2 pl-3 pr-9 aria-disabled:opacity-50 aria-disabled:cursor-not-allowed"
"relative cursor-default select-none py-2 pl-3 pr-9"
)
}
value={recipient}>
@ -67,7 +66,7 @@ export default function RecipientSelector(props: any) {
selected ? "font-semibold" : "font-normal",
"ml-3 block truncate"
)}>
{`${recipient?.name} <${recipient?.email || 'unknown'}>`}
{`${recipient?.name} <${recipient?.email}>`}
</span>
</div>
@ -77,7 +76,7 @@ export default function RecipientSelector(props: any) {
active ? "text-white" : "text-neon-dark",
"absolute inset-y-0 right-0 flex items-center pr-4"
)}>
<CheckIcon className="h-5 w-5" strokeWidth={3} aria-hidden="true" />
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>

View File

@ -2,7 +2,6 @@ import React, { useState } from "react";
import { classNames } from "@documenso/lib";
import { IconButton } from "@documenso/ui";
import { XCircleIcon } from "@heroicons/react/20/solid";
import { FieldType } from "@prisma/client";
import Draggable from "react-draggable";
const stc = require("string-to-color");
@ -47,9 +46,7 @@ export default function SignableField(props: FieldPropsType) {
ref={nodeRef}
className={classNames(
"absolute top-0 left-0 m-auto h-16 w-48 select-none flex-row-reverse text-center text-lg font-bold opacity-80",
[FieldType.SIGNATURE, FieldType.NAME].includes(field.type)
? "cursor-pointer hover:brightness-50"
: "cursor-not-allowed"
field.type === "SIGNATURE" ? "cursor-pointer hover:brightness-50" : "cursor-not-allowed"
)}
style={{
background: stc(props.field.Recipient.email),
@ -57,15 +54,10 @@ export default function SignableField(props: FieldPropsType) {
<div hidden={field?.signature} className="my-4 font-medium">
{field.type === "SIGNATURE" ? "SIGN HERE" : ""}
{field.type === "DATE" ? <small>Date (filled on sign)</small> : ""}
{field.type === "NAME" ? "ENTER NAME HERE" : ""}
</div>
<div
hidden={!field?.signature}
className={classNames(
"m-auto w-auto flex-row-reverse text-center font-medium",
field.type === FieldType.SIGNATURE && "font-qwigley text-5xl",
field.type === FieldType.NAME && "font-sans text-3xl"
)}>
className="font-qwigley m-auto w-auto flex-row-reverse text-center text-5xl font-medium">
{field?.signature?.type === "type" ? (
<div className="my-4">{field?.signature.typedSignature}</div>
) : (

View File

@ -5,7 +5,6 @@ import { Button, IconButton } from "@documenso/ui";
import { Dialog, Transition } from "@headlessui/react";
import { LanguageIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
import SignatureCanvas from "react-signature-canvas";
import { useDebouncedValue } from "../../hooks/use-debounced-value";
const tabs = [
{ name: "Type", icon: LanguageIcon, current: true },
@ -16,9 +15,6 @@ export default function SignatureDialog(props: any) {
const [currentTab, setCurrentTab] = useState(tabs[0]);
const [typedSignature, setTypedSignature] = useState("");
const [signatureEmpty, setSignatureEmpty] = useState(true);
// This is a workaround to prevent the canvas from being rendered when the dialog is closed
// we also need the debounce to avoid rendering while transitions are occuring.
const showCanvas = useDebouncedValue<boolean>(props.open, 1);
let signCanvasRef: any | undefined;
useEffect(() => {
@ -89,7 +85,7 @@ export default function SignatureDialog(props: any) {
</div>
{isCurrentTab("Type") ? (
<div>
<div className="my-7 mb-3 border-b border-gray-300">
<div className="my-8 mb-3 border-b border-gray-300">
<input
value={typedSignature}
onChange={(e) => {
@ -102,7 +98,7 @@ export default function SignatureDialog(props: any) {
placeholder="Kindly type your name"
/>
</div>
<div className="flex flex-row-reverse items-center gap-x-3">
<div className="float-right">
<Button
color="secondary"
onClick={() => {
@ -130,55 +126,47 @@ export default function SignatureDialog(props: any) {
""
)}
{isCurrentTab("Draw") ? (
<div className="" key={props.open ? "closed" : "open"}>
{showCanvas && (
<SignatureCanvas
ref={(ref) => {
signCanvasRef = ref;
}}
canvasProps={{
className: "sigCanvas border-b b-2 border-slate w-full h-full mb-3",
}}
clearOnResize={true}
onEnd={() => {
setSignatureEmpty(signCanvasRef?.isEmpty());
}}
/>
)}
<div className="flex items-center justify-between">
<IconButton
className="block"
icon={TrashIcon}
<div className="">
<SignatureCanvas
ref={(ref) => {
signCanvasRef = ref;
}}
canvasProps={{
className: "sigCanvas border-b b-2 border-slate w-full h-full mb-3",
}}
clearOnResize={true}
onEnd={() => {
setSignatureEmpty(signCanvasRef?.isEmpty());
}}
/>
<IconButton
className="float-left block"
icon={TrashIcon}
onClick={() => {
signCanvasRef?.clear();
setSignatureEmpty(signCanvasRef?.isEmpty());
}}></IconButton>
<div className="float-right mt-10">
<Button
color="secondary"
onClick={() => {
signCanvasRef?.clear();
setSignatureEmpty(signCanvasRef?.isEmpty());
props.onClose();
props.setOpen(false);
setCurrent(tabs[0]);
}}>
Cancel
</Button>
<Button
className="ml-3"
onClick={() => {
props.onClose({
type: "draw",
signatureImage: signCanvasRef.toDataURL("image/png"),
});
}}
/>
<div className="flex flex-row-reverse items-center gap-x-3">
<Button
color="secondary"
onClick={() => {
props.onClose();
props.setOpen(false);
setCurrent(tabs[0]);
}}>
Cancel
</Button>
<Button
className="ml-3"
onClick={() => {
props.onClose({
type: "draw",
signatureImage: signCanvasRef.toDataURL("image/png"),
});
}}
disabled={signatureEmpty}>
Sign
</Button>
</div>
disabled={signatureEmpty}>
Sign
</Button>
</div>
</div>
) : (

View File

@ -1,13 +1,8 @@
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
import { useSubscription } from "@documenso/lib/stripe";
import Navigation from "./navigation";
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import { SubscriptionStatus } from "@prisma/client";
import { useSession } from "next-auth/react";
import { BillingWarning } from "./billing-warning";
function useRedirectToLoginIfUnauthenticated() {
const { data: session, status } = useSession();
@ -35,16 +30,11 @@ function useRedirectToLoginIfUnauthenticated() {
export default function Layout({ children }: any) {
useRedirectToLoginIfUnauthenticated();
const { subscription } = useSubscription();
return (
<>
<div className="min-h-full">
<Navigation />
<Navigation></Navigation>
<main>
<BillingWarning />
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">{children}</div>
</main>
</div>

View File

@ -111,7 +111,7 @@ export default function Login(props: any) {
</div>
<div className="flex items-center justify-between">
<div className="text-sm">
<a href="#" className="text-gray-500 hover:text-neon-700 font-medium">
<a href="#" className="text-neon hover:text-neon font-medium">
Forgot your password?
</a>
</div>
@ -123,7 +123,7 @@ export default function Login(props: any) {
className="group relative flex w-full">
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<LockClosedIcon
className="text-neon-700 group-hover:text-neon-dark-700 h-5 w-5 disabled:disabled:bg-gray-600 disabled:group-hover:bg-gray-600 duration-200"
className="text-neon-dark group-hover:text-neon h-5 w-5 disabled:disabled:bg-gray-600 disabled:group-hover:bg-gray-600"
aria-hidden="true"
/>
</span>
@ -141,7 +141,7 @@ export default function Login(props: any) {
{props.allowSignup ? (
<p className="mt-2 text-center text-sm text-gray-600">
Are you new here?{" "}
<Link href="/signup" className="text-gray-500 hover:text-neon-700 duration-200 font-medium">
<Link href="/signup" className="text-neon hover:text-neon font-medium">
Create a new Account
</Link>
</p>
@ -151,7 +151,7 @@ export default function Login(props: any) {
<Link
href="https://documenso.com"
className="text-neon hover:text-neon font-medium">
Hosted Documenso will be available soon
Hosted Documenso will be availible soon
</Link>
</p>
)}

View File

@ -4,11 +4,8 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { updateUser } from "@documenso/features";
import { getUser } from "@documenso/lib/api";
import { fetchPortalSession, isSubscriptionsEnabled, useSubscription } from "@documenso/lib/stripe";
import { Button } from "@documenso/ui";
import { BillingPlans } from "./billing-plans";
import { CreditCardIcon, KeyIcon, UserCircleIcon } from "@heroicons/react/24/outline";
import { SubscriptionStatus } from "@prisma/client";
import { KeyIcon, UserCircleIcon } from "@heroicons/react/24/outline";
import { useSession } from "next-auth/react";
const subNavigation = [
@ -23,29 +20,20 @@ const subNavigation = [
href: "/settings/password",
icon: KeyIcon,
current: false,
}
},
];
if (process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true") {
subNavigation.push({
name: "Billing",
href: "/settings/billing",
icon: CreditCardIcon,
current: false,
});
}
function classNames(...classes: any) {
return classes.filter(Boolean).join(" ");
}
export default function Setttings() {
const session = useSession();
const { subscription, hasSubscription } = useSubscription();
const [user, setUser] = useState({
email: "",
name: "",
});
useEffect(() => {
getUser().then((res: any) => {
res.json().then((j: any) => {
@ -170,7 +158,6 @@ export default function Setttings() {
<Button onClick={() => updateUser(user)}>Save</Button>
</div>
</form>
<div
hidden={subNavigation.filter((e) => e.current)[0]?.name !== subNavigation[1].name}
className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9">
@ -184,72 +171,9 @@ export default function Setttings() {
</div>
</div>
</div>
<div
hidden={!subNavigation.at(2) || subNavigation.find((e) => e.current)?.name !== subNavigation.at(2)?.name}
className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9">
{/* Billing section */}
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg font-medium leading-6 text-gray-900">Billing</h2>
{!isSubscriptionsEnabled() && (
<p className="mt-2 text-sm text-gray-500">
Subscriptions are not enabled on this instance, you have nothing to do here.
</p>
)}
{isSubscriptionsEnabled() && (
<>
<p className="mt-1 text-sm text-gray-500">
Your subscription is currently{" "}
<strong>
{subscription?.status &&
subscription?.status !== SubscriptionStatus.INACTIVE
? "Active"
: "Inactive"}
</strong>
.
</p>
{subscription?.status === SubscriptionStatus.PAST_DUE && (
<p className="mt-1 text-sm text-red-500">
Your subscription is past due. Please update your payment details to
continue using the service without interruption.
</p>
)}
<div className="mt-8">
<div className="grid grid-cols-1 lg:grid-cols-2">
<BillingPlans />
</div>
{subscription && (
<Button
onClick={() => {
if (isSubscriptionsEnabled() && subscription?.customerId) {
fetchPortalSession({
id: subscription.customerId,
}).then((res) => {
if (res.success) {
window.location.href = res.url;
}
});
}
}}>
Manage my subscription
</Button>
)}
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="mt-10 max-w-[1100px]" hidden={!!user.email}>
<div className="ph-item">
<div className="ph-col-12">

View File

@ -187,7 +187,7 @@ export default function Signup(props: { source: string }) {
</div>
<p className="mt-2 text-center text-sm text-gray-600">
Already have an account?{" "}
<Link href="/login" className="text-gray-500 hover:text-neon-700 font-medium">
<Link href="/login" className="text-neon hover:text-neon font-medium">
Sign In
</Link>
</p>

View File

@ -1,18 +0,0 @@
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay: number) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -1,16 +1,12 @@
/** @type {import('next').NextConfig} */
require("dotenv").config({ path: "../../.env" });
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: false,
env: {
IS_PULL_REQUEST: process.env.IS_PULL_REQUEST,
RENDER_EXTERNAL_URL: process.env.RENDER_EXTERNAL_URL,
},
};
const transpileModules = require("next-transpile-modules")([
const withTM = require("next-transpile-modules")([
"@documenso/prisma",
"@documenso/lib",
"@documenso/ui",
@ -19,10 +15,8 @@ const transpileModules = require("next-transpile-modules")([
"@documenso/signing",
"react-signature-canvas",
]);
const plugins = [
transpileModules
];
const plugins = [];
plugins.push(withTM);
const moduleExports = () => plugins.reduce((acc, next) => next(acc), nextConfig);

View File

@ -7,27 +7,36 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"db-studio": "prisma db studio",
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook"
"db-studio": "prisma db studio"
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/pdf": "*",
"@documenso/prisma": "*",
"@documenso/ui": "*",
"@headlessui/react": "^1.7.4",
"@heroicons/react": "^2.0.13",
"@pdf-lib/fontkit": "^1.1.1",
"@tailwindcss/forms": "^0.5.3",
"@types/bcryptjs": "^2.4.2",
"@types/filesystem": "^0.0.32",
"@types/react-dom": "18.0.9",
"avatar-from-initials": "^1.0.3",
"base64-arraybuffer": "^1.0.2",
"bcryptjs": "^2.4.3",
"dotenv": "^16.0.3",
"eslint": "8.27.0",
"eslint-config-next": "13.0.3",
"file-loader": "^6.2.0",
"formidable": "^3.2.5",
"next": "13.2.4",
"next-auth": "^4.22.0",
"install": "^0.13.0",
"next": "13.0.3",
"next-auth": ">=4.20.1",
"next-transpile-modules": "^10.0.0",
"node-forge": "^1.3.1",
"node-signpdf": "^1.5.0",
"nodemailer": "^6.9.0",
"nodemailer-sendgrid": "^1.0.3",
"npm": "^9.1.3",
"pdf-lib": "^1.17.1",
"placeholder-loading": "^0.6.0",
"react": "18.2.0",
@ -37,30 +46,20 @@
"react-pdf": "^6.2.2",
"react-resizable": "^3.0.4",
"react-tooltip": "^5.7.2",
"sass": "^1.57.1",
"short-uuid": "^4.2.2",
"string-to-color": "^2.2.2"
"string-to-color": "^2.2.2",
"typescript": "4.8.4"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"@types/bcryptjs": "^2.4.2",
"@types/filesystem": "^0.0.32",
"@types/formidable": "^2.0.5",
"@types/node": "^18.11.18",
"@types/nodemailer": "^6.4.7",
"@types/nodemailer-sendgrid": "^1.0.0",
"@types/react-dom": "18.0.9",
"@types/react-pdf": "^6.2.0",
"@types/react-resizable": "^3.0.3",
"autoprefixer": "^10.4.13",
"dotenv": "^16.0.3",
"eslint": "8.27.0",
"eslint-config-next": "13.0.3",
"file-loader": "^6.2.0",
"next-transpile-modules": "^10.0.0",
"postcss": "^8.4.19",
"sass": "^1.57.1",
"stripe-cli": "^0.1.0",
"tailwindcss": "^3.2.4",
"typescript": "4.8.4"
"tailwindcss": "^3.2.4"
}
}
}

View File

@ -1,7 +1,6 @@
import { ReactElement, ReactNode } from "react";
import { NextPage } from "next";
import type { AppProps } from "next/app";
import { SubscriptionProvider } from "@documenso/lib/stripe/providers/subscription-provider";
import "../../../node_modules/placeholder-loading/src/scss/placeholder-loading.scss";
import "../../../node_modules/react-resizable/css/styles.css";
import "../styles/tailwind.css";
@ -21,15 +20,13 @@ type AppPropsWithLayout = AppProps & {
export default function App({
Component,
pageProps: { session, initialSubscription, ...pageProps },
pageProps: { session, ...pageProps },
}: AppPropsWithLayout) {
const getLayout = Component.getLayout || ((page: any) => page);
return (
<SessionProvider session={session}>
<SubscriptionProvider initialSubscription={initialSubscription}>
<Toaster position="top-center" />
{getLayout(<Component {...pageProps} />)}
</SubscriptionProvider>
<Toaster position="top-center"></Toaster>
{getLayout(<Component {...pageProps} />)}
</SessionProvider>
);
}

View File

@ -1,6 +1,9 @@
import { Head, Html, Main, NextScript } from "next/document";
import Script from "next/script";
export default function Document(props) {
let pageProps = props.__NEXT_DATA__?.props?.pageProps;
export default function Document() {
return (
<Html className="h-full scroll-smooth bg-gray-100 font-normal antialiased" lang="en">
<Head>

View File

@ -63,7 +63,6 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
});
@ -87,11 +86,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
where: {
documentId: document.id,
type: { in: [FieldType.DATE, FieldType.TEXT] },
recipientId: { in: signedRecipients.map((r) => r.id) },
},
include: {
Recipient: true,
}
});
// Insert fields other than signatures
@ -103,7 +98,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
month: "long",
day: "numeric",
year: "numeric",
}).format(field.Recipient?.signedAt ?? new Date())
}).format(new Date())
: field.customText || "",
field.positionX,
field.positionY,
@ -162,11 +157,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
signedField.Signature.typedSignature,
signedField.positionX,
signedField.positionY,
signedField.page,
// useHandwritingFont only for typed signatures
signedField.type === FieldType.SIGNATURE,
// fontSize only for name field
signedField.type === FieldType.NAME ? 30 : undefined
signedField.page
);
} else {
documentWithInserts = document.document;

View File

@ -4,7 +4,6 @@ import { defaultHandler, defaultResponder } from "@documenso/lib/server";
import { getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import formidable from "formidable";
import { isSubscribedServer } from "@documenso/lib/stripe";
export const config = {
api: {
@ -16,17 +15,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const form = formidable();
const user = await getUserFromToken(req, res);
if (!user) {
return res.status(401).end();
};
const isSubscribed = await isSubscribedServer(req);
if (!isSubscribed) {
throw new Error("User is not subscribed.");
}
if (!user) return;
form.parse(req, async (err, fields, files) => {
if (err) throw err;

View File

@ -1 +0,0 @@
export { checkoutSessionHandler as default } from '@documenso/lib/stripe/handlers/checkout-session'

View File

@ -1 +0,0 @@
export { portalSessionHandler as default } from "@documenso/lib/stripe/handlers/portal-session";

View File

@ -1 +0,0 @@
export { getSubscriptionHandler as default } from '@documenso/lib/stripe/handlers/get-subscription'

View File

@ -1,5 +0,0 @@
export const config = {
api: { bodyParser: false },
};
export { webhookHandler as default } from "@documenso/lib/stripe/handlers/webhook";

View File

@ -1,4 +1,4 @@
import { ChangeEvent, ReactElement } from "react";
import { ReactElement } from "react";
import Head from "next/head";
import Link from "next/link";
import { uploadDocument } from "@documenso/features";
@ -20,15 +20,12 @@ import {
} from "@prisma/client";
import { truncate } from "fs";
import { Tooltip as ReactTooltip } from "react-tooltip";
import { useSubscription } from "@documenso/lib/stripe";
type FormValues = {
document: File;
};
const DashboardPage: NextPageWithLayout = (props: any) => {
const { hasSubscription } = useSubscription();
const stats = [
{
name: "Draft",
@ -65,27 +62,26 @@ const DashboardPage: NextPageWithLayout = (props: any) => {
<dl className="mt-8 grid gap-5 md:grid-cols-3 ">
{stats.map((item) => (
<Link href={item.link} key={item.name}>
<div className="overflow-hidden rounded-lg bg-white px-4 py-3 shadow hover:shadow-lg duration-300 sm:py-5 md:p-6">
<dt className="truncate text-sm font-medium text-gray-700 ">
<div className="overflow-hidden rounded-lg bg-white px-4 py-3 shadow sm:py-5 md:p-6">
<dt className="truncate text-sm font-medium text-gray-500 ">
<item.icon
className="text-neon-600 mr-3 inline h-5 w-5 flex-shrink-0 sm:h-6 sm:w-6"
className="text-neon mr-3 inline h-5 w-5 flex-shrink-0 sm:h-6 sm:w-6"
aria-hidden="true"></item.icon>
{item.name}
</dt>
<dd className="mt-3 text-2xl font-semibold tracking-tight text-gray-900 sm:text-3xl">
<dd className="mt-1 text-2xl font-semibold tracking-tight text-gray-900 sm:text-3xl">
{getStat(item.name, props)}
</dd>
</div>
</Link>
))}
</dl>
<div className="mt-12">
<input
id="fileUploadHelper"
type="file"
accept="application/pdf"
onChange={(event: ChangeEvent) => {
onChange={(event: any) => {
uploadDocument(event);
}}
hidden
@ -93,15 +89,11 @@ const DashboardPage: NextPageWithLayout = (props: any) => {
</div>
<div
onClick={() => {
if (hasSubscription) {
document?.getElementById("fileUploadHelper")?.click();
}
document?.getElementById("fileUploadHelper")?.click();
}}
aria-disabled={!hasSubscription}
className="group hover:border-neon-600 duration-200 relative block w-full cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 aria-disabled:opacity-50 aria-disabled:pointer-events-none">
className="hover:border-neon relative block w-full cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
<svg
className="mx-auto h-12 w-12 text-gray-400 group-hover:text-gray-700 duration-200"
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 00 20 25"
@ -112,8 +104,7 @@ const DashboardPage: NextPageWithLayout = (props: any) => {
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
<span id="add_document" className="text-gray-500 group-hover:text-neon-700 mt-2 block text-sm font-medium duration-200">
<span id="add_document" className="text-neon mt-2 block text-sm font-medium">
Add a new PDF document.
</span>
</div>

View File

@ -20,22 +20,14 @@ import {
} from "@heroicons/react/24/outline";
import { DocumentStatus } from "@prisma/client";
import { Tooltip as ReactTooltip } from "react-tooltip";
import { useSubscription } from "@documenso/lib/stripe";
const DocumentsPage: NextPageWithLayout = (props: any) => {
const router = useRouter();
const { hasSubscription } = useSubscription();
const [documents, setDocuments]: any[] = useState([]);
const [filteredDocuments, setFilteredDocuments] = useState([]);
const [loading, setLoading] = useState(true);
type statusFilterType = {
label: string;
value: DocumentStatus | "ALL";
};
const statusFilters: statusFilterType[] = [
const statusFilters = [
{ label: "All", value: "ALL" },
{ label: "Draft", value: "DRAFT" },
{ label: "Waiting for others", value: "PENDING" },
@ -91,20 +83,6 @@ const DocumentsPage: NextPageWithLayout = (props: any) => {
return filteredDocuments;
}
function handleStatusFilterChange(status: statusFilterType) {
router.replace(
{
pathname: router.pathname,
query: { filter: status.value },
},
undefined,
{
shallow: true, // Perform a shallow update, without reloading the page
}
);
setSelectedStatusFilter(status);
}
function wasXDaysAgoOrLess(documentDate: Date, lastXDays: number): boolean {
if (lastXDays < 0) return true;
@ -137,7 +115,6 @@ const DocumentsPage: NextPageWithLayout = (props: any) => {
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<Button
icon={DocumentPlusIcon}
disabled={!hasSubscription}
onClick={() => {
document?.getElementById("fileUploadHelper")?.click();
}}>
@ -145,26 +122,26 @@ const DocumentsPage: NextPageWithLayout = (props: any) => {
</Button>
</div>
</div>
<div className="mt-3 mb-12 flex flex-row-reverse items-center gap-x-4">
<div className="pt-5 block w-fit">
<div className="mt-3 mb-12">
<div className="float-right ml-3 mt-7 block w-fit">
{filteredDocuments.length != 1 ? filteredDocuments.length + " Documents" : "1 Document"}
</div>
<SelectBox
className="block w-1/4"
className="float-right block w-1/4"
label="Created"
options={createdFilter}
value={selectedCreatedFilter}
onChange={setSelectedCreatedFilter}
/>
<SelectBox
className="block w-1/4"
className="float-right ml-3 block w-1/4"
label="Status"
options={statusFilters}
value={selectedStatusFilter}
onChange={handleStatusFilterChange}
onChange={setSelectedStatusFilter}
/>
</div>
<div className="mt-8 max-w-[1100px]" hidden={!loading}>
<div className="mt-20 max-w-[1100px]" hidden={!loading}>
<div className="ph-item">
<div className="ph-col-12">
<div className="ph-picture"></div>
@ -181,7 +158,7 @@ const DocumentsPage: NextPageWithLayout = (props: any) => {
</div>
</div>
</div>
<div className="mt-8 flex flex-col" hidden={!documents.length || loading}>
<div className="mt-28 flex flex-col" hidden={!documents.length || loading}>
<div
className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8"
hidden={!documents.length || loading}>
@ -224,13 +201,13 @@ const DocumentsPage: NextPageWithLayout = (props: any) => {
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{document.title || "#" + document.id}
</td>
<td className="whitespace-nowrap inline-flex py-3 gap-x-2 gap-y-1 flex-wrap max-w-[250px] text-sm text-gray-500">
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{document.Recipient.map((item: any) => (
<div key={item.id}>
{item.sendStatus === "NOT_SENT" ? (
<span
id="sent_icon"
className="flex-shrink-0 h-6 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
className="inline-block flex-shrink-0 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
{item.name ? item.name + " <" + item.email + ">" : item.email}
</span>
) : (
@ -240,8 +217,8 @@ const DocumentsPage: NextPageWithLayout = (props: any) => {
<span id="sent_icon">
<span
id="sent_icon"
className="flex-shrink-0 h-6 inline-flex items-center rounded-full bg-yellow-200 px-2 py-0.5 text-xs font-medium text-yellow-800">
<EnvelopeIcon className="mr-1 inline h-4"></EnvelopeIcon>
className="inline-block flex-shrink-0 rounded-full bg-yellow-200 px-2 py-0.5 text-xs font-medium text-green-800">
<EnvelopeIcon className="mr-1 inline h-5"></EnvelopeIcon>
{item.name ? item.name + " <" + item.email + ">" : item.email}
</span>
</span>
@ -253,9 +230,9 @@ const DocumentsPage: NextPageWithLayout = (props: any) => {
<span id="read_icon">
<span
id="sent_icon"
className="flex-shrink-0 h-6 inline-flex items-center rounded-full bg-yellow-200 px-2 py-0.5 text-xs font-medium text-yellow-800">
<CheckIcon className="-mr-2 inline h-4"></CheckIcon>
<CheckIcon className="mr-1 inline h-4"></CheckIcon>
className="inline-block flex-shrink-0 rounded-full bg-yellow-200 px-2 py-0.5 text-xs font-medium text-green-800">
<CheckIcon className="-mr-2 inline h-5"></CheckIcon>
<CheckIcon className="mr-1 inline h-5"></CheckIcon>
{item.name ? item.name + " <" + item.email + ">" : item.email}
</span>
</span>
@ -264,7 +241,7 @@ const DocumentsPage: NextPageWithLayout = (props: any) => {
)}
{item.signingStatus === "SIGNED" ? (
<span id="signed_icon">
<span className="flex-shrink-0 h-6 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
<span className="inline-block flex-shrink-0 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
<CheckBadgeIcon className="mr-1 inline h-5"></CheckBadgeIcon>{" "}
{item.email}
</span>

View File

@ -4,7 +4,6 @@ import { useRouter } from "next/router";
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib";
import { getDocument } from "@documenso/lib/query";
import { getUserFromToken } from "@documenso/lib/server";
import { useSubscription } from "@documenso/lib/stripe";
import { Breadcrumb, Button } from "@documenso/ui";
import PDFEditor from "../../../components/editor/pdf-editor";
import Layout from "../../../components/layout";
@ -15,7 +14,6 @@ import { Document as PrismaDocument } from "@prisma/client";
const DocumentsDetailPage: NextPageWithLayout = (props: any) => {
const router = useRouter();
const { hasSubscription } = useSubscription();
return (
<div className="mt-4">

View File

@ -4,7 +4,7 @@ import { NEXT_PUBLIC_WEBAPP_URL, classNames } from "@documenso/lib";
import { createOrUpdateRecipient, deleteRecipient, sendSigningRequests } from "@documenso/lib/api";
import { getDocument } from "@documenso/lib/query";
import { getUserFromToken } from "@documenso/lib/server";
import { Breadcrumb, Button, Dialog, IconButton, Tooltip } from "@documenso/ui";
import { Breadcrumb, Button, Dialog, IconButton } from "@documenso/ui";
import Layout from "../../../components/layout";
import { NextPageWithLayout } from "../../_app";
import {
@ -21,16 +21,12 @@ import {
import { DocumentStatus, Document as PrismaDocument, Recipient } from "@prisma/client";
import { FormProvider, useFieldArray, useForm, useWatch } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useSubscription } from "@documenso/lib/stripe";
export type FormValues = {
signers: Array<Pick<Recipient, 'id' | 'email' | 'name' | 'sendStatus' | 'readStatus' | 'signingStatus'>>;
signers: Array<Pick<Recipient, 'id' | 'email' | 'name' | 'sendStatus'>>;
};
type FormSigner = FormValues["signers"][number];
const RecipientsPage: NextPageWithLayout = (props: any) => {
const { hasSubscription } = useSubscription();
const title: string = `"` + props?.document?.title + `"` + "Recipients | Documenso";
const breadcrumbItems = [
{
@ -68,7 +64,7 @@ const RecipientsPage: NextPageWithLayout = (props: any) => {
});
const formValues = useWatch({ control, name: "signers" });
const cancelButtonRef = useRef(null);
const hasEmailError = (formValue: FormSigner): boolean => {
const hasEmailError = (formValue: any): boolean => {
const index = formValues.findIndex((e) => e.id === formValue.id);
return !!errors?.signers?.[index]?.email;
};
@ -118,7 +114,6 @@ const RecipientsPage: NextPageWithLayout = (props: any) => {
: setOpen(true);
}}
disabled={
!hasSubscription ||
(formValues.length || 0) === 0 ||
!formValues.some(
(r) => r.email && !hasEmailError(r) && r.sendStatus === "NOT_SENT"
@ -146,7 +141,7 @@ const RecipientsPage: NextPageWithLayout = (props: any) => {
trigger();
}}>
<ul role="list" className="divide-y divide-gray-200">
{fields.map((item, index) => (
{fields.map((item: any, index: number) => (
<li
key={index}
className="group w-full border-0 px-2 py-3 hover:bg-green-50 sm:py-4">
@ -267,46 +262,38 @@ const RecipientsPage: NextPageWithLayout = (props: any) => {
</div>
{props.document.status !== DocumentStatus.COMPLETED && (
<div className="mr-1 flex">
<Tooltip label="Resend">
<IconButton
icon={PaperAirplaneIcon}
disabled={
!item.id ||
item.sendStatus !== "SENT" ||
item.signingStatus === "SIGNED" ||
loading
<IconButton
icon={PaperAirplaneIcon}
disabled={
!item.id ||
item.sendStatus !== "SENT" ||
item.signingStatus === "SIGNED" ||
loading
}
color="secondary"
className="my-auto mr-4 h-9"
onClick={() => {
if (confirm("Resend this signing request?")) {
setLoading(true);
sendSigningRequests(props.document, [item.id]).finally(() => {
setLoading(false);
});
}
onClick={(event: any) => {
event.preventDefault();
event.stopPropagation();
if (confirm("Resend this signing request?")) {
setLoading(true);
sendSigningRequests(props.document, [item.id]).finally(() => {
setLoading(false);
});
}
}}
className="mx-1 group-hover:text-neon-dark group-hover:disabled:text-gray-400"
/>
</Tooltip>
<Tooltip label="Delete">
<IconButton
icon={TrashIcon}
disabled={!item.id || item.sendStatus === "SENT" || loading}
onClick={(event: any) => {
event.preventDefault();
event.stopPropagation();
if (confirm("Delete this signing request?")) {
const removedItem = { ...fields }[index];
remove(index);
deleteRecipient(item)?.catch((err) => {
append(removedItem);
});
}
}}
className="mx-1 group-hover:text-neon-dark group-hover:disabled:text-gray-400"
/>
</Tooltip>
}}>
Resend
</IconButton>
<IconButton
icon={TrashIcon}
disabled={!item.id || item.sendStatus === "SENT" || loading}
onClick={() => {
const removedItem = { ...fields }[index];
remove(index);
deleteRecipient(item)?.catch((err) => {
append(removedItem);
});
}}
className="group-hover:text-neon-dark group-hover:disabled:text-gray-400"
/>
</div>
)}
</div>

View File

@ -73,7 +73,7 @@ export async function getServerSideProps(context: any) {
return {
redirect: {
permanent: false,
destination: `/documents/${recipient.Document.id}/signed?token=${recipientToken}`,
destination: `/documents/${recipient.Document.id}/signed`,
},
};
}

View File

@ -1,5 +1,4 @@
import Head from "next/head";
import { getUserFromToken } from "@documenso/lib/server";
import Login from "../components/login";
export default function LoginPage(props: any) {
@ -14,21 +13,11 @@ export default function LoginPage(props: any) {
}
export async function getServerSideProps(context: any) {
const user = await getUserFromToken(context.req, context.res);
if (user)
return {
redirect: {
source: "/login",
destination: "/dashboard",
permanent: false,
},
};
const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP === "true";
const ALLOW_SIGNUP = process.env.ALLOW_SIGNUP === "true";
return {
props: {
ALLOW_SIGNUP,
ALLOW_SIGNUP: ALLOW_SIGNUP,
},
};
}

View File

@ -1 +0,0 @@
export { default } from ".";

View File

@ -1,6 +1,5 @@
import { NextPageContext } from "next";
import Head from "next/head";
import { getUserFromToken } from "@documenso/lib/server";
import Signup from "../components/signup";
export default function SignupPage(props: { source: string }) {
@ -15,7 +14,7 @@ export default function SignupPage(props: { source: string }) {
}
export async function getServerSideProps(context: any) {
if (process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "true")
if (process.env.ALLOW_SIGNUP !== "true")
return {
redirect: {
destination: "/login",
@ -23,16 +22,6 @@ export async function getServerSideProps(context: any) {
},
};
const user = await getUserFromToken(context.req, context.res);
if (user)
return {
redirect: {
source: "/signup",
destination: "/dashboard",
permanent: false,
},
};
const signupSource: string = context.query["source"];
return {
props: {

View File

@ -1,24 +0,0 @@
declare namespace NodeJS {
export interface ProcessEnv {
DATABASE_URL: string;
NEXT_PUBLIC_WEBAPP_URL: string;
NEXTAUTH_SECRET: string;
NEXTAUTH_URL: string;
SENDGRID_API_KEY?: string;
SMTP_MAIL_HOST?: string;
SMTP_MAIL_PORT?: string;
SMTP_MAIL_USER?: string;
SMTP_MAIL_PASSWORD?: string;
MAIL_FROM: string;
STRIPE_API_KEY?: string;
STRIPE_WEBHOOK_SECRET?: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID?: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID?: string;
NEXT_PUBLIC_ALLOW_SIGNUP?: string;
NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS?: string;
}
}

View File

@ -16,48 +16,9 @@ module.exports = {
qwigley: ["Qwigley", "serif"],
},
colors: {
neon: {
DEFAULT: "#37F095",
50: "#E2FDF0",
100: "#CFFBE5",
200: "#A9F9D1",
300: "#83F6BD",
400: "#5DF3A9",
500: "#37F095",
600: "#11DE79",
700: "#0DAA5D",
800: "#097640",
900: "#054224",
950: "#032816",
},
"neon-dark": {
DEFAULT: "#2CC077",
50: "#B5EED2",
100: "#A5EAC8",
200: "#84E3B4",
300: "#62DBA0",
400: "#41D48B",
500: "#2CC077",
600: "#22925B",
700: "#17653E",
800: "#0D3722",
900: "#020906",
950: "#000000",
},
brown: {
DEFAULT: "#353434",
50: "#918F8F",
100: "#878585",
200: "#737171",
300: "#5E5C5C",
400: "#4A4848",
500: "#353434",
600: "#191818",
700: "#000000",
800: "#000000",
900: "#000000",
950: "#000000",
},
neon: "#37f095",
"neon-dark": "#2CC077",
brown: "#353434",
},
borderRadius: {
"4xl": "2rem",

View File

@ -21,6 +21,6 @@
"../../packages/types/next-auth.d.ts",
"**/*.ts",
"**/*.tsx"
, "../../packages/lib/process-env.d.ts" ],
],
"exclude": ["node_modules"]
}

View File

@ -1,8 +1,6 @@
name: documenso
services:
database:
image: postgres:15
container_name: database
environment:
- POSTGRES_USER=documenso
- POSTGRES_PASSWORD=password
@ -12,7 +10,6 @@ services:
inbucket:
image: inbucket/inbucket
container_name: mailserver
ports:
- 9000:9000
- 2500:2500

View File

@ -33,7 +33,7 @@ services:
- SMTP_MAIL_USER=username
- SMTP_MAIL_PASSWORD=password
- MAIL_FROM=admin@example.com
- NEXT_PUBLIC_ALLOW_SIGNUP=true
- ALLOW_SIGNUP=true
ports:
- 3000:3000
volumes:

1
documenso Submodule

Submodule documenso added at 0dcab27e65

7862
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@
"docker:compose": "docker-compose -f ./docker/compose-without-app.yml",
"docker:compose-up": "npm run docker:compose -- up -d",
"docker:compose-down": "npm run docker:compose -- down",
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook",
"dx": "npm install && run-s docker:compose-up db-migrate:dev",
"d": "npm install && run-s docker:compose-up db-migrate:dev && npm run db-seed && npm run dev"
},
@ -27,31 +26,33 @@
"@documenso/prisma": "*",
"@headlessui/react": "^1.7.4",
"@heroicons/react": "^2.0.13",
"@tailwindcss/forms": "^0.5.3",
"@types/bcryptjs": "^2.4.2",
"@types/node": "18.11.9",
"@types/react-dom": "18.0.9",
"@types/react-signature-canvas": "^1.0.2",
"avatar-from-initials": "^1.0.3",
"bcryptjs": "^2.4.3",
"next": "13.2.4",
"dotenv": "^16.0.3",
"eslint": "8.27.0",
"eslint-config-next": "13.0.3",
"install": "^0.13.0",
"next": "13.0.3",
"next-auth": ">=4.20.1",
"next-transpile-modules": "^10.0.0",
"npm": "^9.1.3",
"pdf-lib": "^1.17.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.41.5",
"react-hot-toast": "^2.4.0",
"react-signature-canvas": "^1.0.6"
"react-signature-canvas": "^1.0.6",
"typescript": "4.8.4"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/bcryptjs": "^2.4.2",
"@types/node": "18.11.9",
"@types/react-dom": "18.0.9",
"@types/react-signature-canvas": "^1.0.2",
"dotenv": "^16.0.3",
"eslint": "8.27.0",
"eslint-config-next": "13.0.3",
"next-transpile-modules": "^10.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.5",
"typescript": "4.8.4"
"prettier-plugin-tailwindcss": "^0.2.5"
}
}
}

View File

@ -1,40 +0,0 @@
The Documenso Commercial License (the “Commercial License”)
Copyright (c) 2023 Documenso, Inc
With regard to the Documenso Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, an agreement governing
the use of the Software, as mutually agreed by you and Documenso, Inc ("Documenso"),
and otherwise have a valid Documenso Enterprise Edition subscription ("Commercial Subscription").
Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software.
You agree that Documenso and/or its licensors (as applicable) retain all right, title and interest in
and to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Commercial Subscription for the correct number of hosts.
Notwithstanding the foregoing, you may copy and modify the Software for development
and testing purposes, without requiring a subscription. You agree that Documenso and/or
its licensors (as applicable) retain all right, title and interest in and to all such
modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This Commercial License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Documenso Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@ -1,15 +0,0 @@
<div align="center"style="padding: 12px">
<a href="https://github.com/documenso/documenso.com">
<img width="250px" src="https://user-images.githubusercontent.com/1309312/224986248-5b8a5cdc-2dc1-46b9-a354-985bb6808ee0.png" alt="Documenso Logo">
</a>
<a href="https://dub.sh/documenso-enterprise">Contact Us</a>
</div>
# Enterprise Edition
Welcome to the Enterprise Edition ("/ee") of Documenso.com.
The [/ee](https://github.com/documenso/documenso/tree/main/packages/features/ee) subfolder is the home of all the **Enterprise Edition** features from our [hosted](https://documenso.com/pricing) plan. To use this code in production you need and valid Enterprise License.
> IMPORTANT: This subfolder is licensed differently than the rest of our [main repo](https://github.com/documenso/documenso). [Contact us](https://dub.sh/documenso-enterprise) to learn more.

View File

@ -1,10 +1,9 @@
import { ChangeEvent } from "react";
import router from "next/router";
import { NEXT_PUBLIC_WEBAPP_URL } from "../lib/constants";
import toast from "react-hot-toast";
export const uploadDocument = async (event: ChangeEvent) => {
if (event.target instanceof HTMLInputElement && event.target?.files && event.target.files[0]) {
export const uploadDocument = async (event: any) => {
if (event.target.files && event.target.files[0]) {
const body = new FormData();
const document = event.target.files[0];
const fileName: string = event.target.files[0].name;
@ -13,31 +12,25 @@ export const uploadDocument = async (event: ChangeEvent) => {
toast.error("Non-PDF documents are not supported yet.");
return;
}
body.append("document", document || "");
await toast.promise(
fetch("/api/documents", {
method: "POST",
body,
}).then((response: Response) => {
if (!response.ok) {
throw new Error("Could not upload document");
const response: any = await toast
.promise(
fetch("/api/documents", {
method: "POST",
body,
}),
{
loading: "Uploading document...",
success: `${fileName} uploaded successfully.`,
error: "Could not upload document :/",
}
)
.then((response: Response) => {
response.json().then((createdDocumentIdFromBody) => {
router.push(
`${NEXT_PUBLIC_WEBAPP_URL}/documents/${createdDocumentIdFromBody}/recipients`
);
});
}),
{
loading: "Uploading document...",
success: `${fileName} uploaded successfully.`,
error: "Could not upload document :/",
}
).catch((_err) => {
// Do nothing
});
});
}
};

View File

@ -1,4 +1 @@
export const NEXT_PUBLIC_WEBAPP_URL =
process.env.IS_PULL_REQUEST === "true"
? process.env.RENDER_EXTERNAL_URL
: process.env.NEXT_PUBLIC_WEBAPP_URL;
export const NEXT_PUBLIC_WEBAPP_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;

View File

@ -11,8 +11,8 @@ export const signingRequestTemplate = (
user: any
) => {
const customContent = `
<p style="margin: 30px 0px; text-align: center">
<a href="${ctaLink}" style="background-color: #37f095; white-space: nowrap; color: white; border-color: transparent; border-width: 1px; border-radius: 0.375rem; font-size: 18px; padding-left: 16px; padding-right: 16px; padding-top: 10px; padding-bottom: 10px; text-decoration: none; margin-top: 4px; margin-bottom: 4px;">
<p style="margin: 30px;">
<a href="${ctaLink}" style="background-color: #37f095; color: white; border-color: transparent; border-width: 1px; border-radius: 0.375rem; font-size: 18px; padding-left: 16px; padding-right: 16px; padding-top: 10px; padding-bottom: 10px; text-decoration: none; margin-top: 4px; margin-bottom: 4px;">
${ctaLabel}
</a>
</p>

View File

@ -4,10 +4,6 @@
"private": true,
"main": "index.ts",
"dependencies": {
"@documenso/prisma": "*",
"@prisma/client": "^4.8.1",
"bcryptjs": "^2.4.3",
"micro": "^10.0.1",
"stripe": "^12.4.0"
"bcryptjs": "^2.4.3"
}
}
}

View File

@ -1,24 +0,0 @@
declare namespace NodeJS {
export interface ProcessEnv {
DATABASE_URL: string;
NEXT_PUBLIC_WEBAPP_URL: string;
NEXTAUTH_SECRET: string;
NEXTAUTH_URL: string;
SENDGRID_API_KEY?: string;
SMTP_MAIL_HOST?: string;
SMTP_MAIL_PORT?: string;
SMTP_MAIL_USER?: string;
SMTP_MAIL_PASSWORD?: string;
MAIL_FROM: string;
STRIPE_API_KEY?: string;
STRIPE_WEBHOOK_SECRET?: string;
STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID?: string;
STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID?: string;
NEXT_PUBLIC_ALLOW_SIGNUP?: string;
NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS?: string;
}
}

View File

@ -1,7 +0,0 @@
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_API_KEY!, {
apiVersion: "2022-11-15",
typescript: true,
});

View File

@ -1,15 +0,0 @@
export const STRIPE_PLANS = [
{
name: "Community Plan",
prices: {
monthly: {
price: 30,
priceId: process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID ?? "",
},
yearly: {
price: 300,
priceId: process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID ?? "",
},
},
},
];

View File

@ -1,23 +0,0 @@
import { CheckoutSessionRequest, CheckoutSessionResponse } from "../handlers/checkout-session"
export type FetchCheckoutSessionOptions = CheckoutSessionRequest['body']
export const fetchCheckoutSession = async ({
id,
priceId
}: FetchCheckoutSessionOptions) => {
const response = await fetch('/api/stripe/checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id,
priceId
})
});
const json: CheckoutSessionResponse = await response.json();
return json;
}

View File

@ -1,14 +0,0 @@
import { GetSubscriptionResponse } from "../handlers/get-subscription";
export const fetchSubscription = async () => {
const response = await fetch("/api/stripe/subscription", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const json: GetSubscriptionResponse = await response.json();
return json;
};

View File

@ -1,19 +0,0 @@
import { PortalSessionRequest, PortalSessionResponse } from "../handlers/portal-session";
export type FetchPortalSessionOptions = PortalSessionRequest["body"];
export const fetchPortalSession = async ({ id }: FetchPortalSessionOptions) => {
const response = await fetch("/api/stripe/portal-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
}),
});
const json: PortalSessionResponse = await response.json();
return json;
};

View File

@ -1,35 +0,0 @@
import { GetServerSideProps, GetServerSidePropsContext, NextApiRequest } from "next";
import { SubscriptionStatus } from "@prisma/client";
import { getToken } from "next-auth/jwt";
export const isSubscriptionsEnabled = () => {
return process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true";
};
export const isSubscribedServer = async (
req: NextApiRequest | GetServerSidePropsContext["req"]
) => {
const { default: prisma } = await import("@documenso/prisma");
if (!isSubscriptionsEnabled()) {
return true;
}
const token = await getToken({
req,
});
if (!token || !token.email) {
return false;
}
const subscription = await prisma.subscription.findFirst({
where: {
User: {
email: token.email,
},
},
});
return subscription !== null && subscription.status !== SubscriptionStatus.INACTIVE;
};

View File

@ -1,92 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@documenso/prisma";
import { stripe } from "../client";
import { getToken } from "next-auth/jwt";
export type CheckoutSessionRequest = {
body: {
id?: string;
priceId: string;
};
};
export type CheckoutSessionResponse =
| {
success: false;
message: string;
}
| {
success: true;
url: string;
};
export const checkoutSessionHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
if (req.method !== "POST") {
return res.status(405).json({
success: false,
message: "Method not allowed",
});
}
const token = await getToken({
req,
});
if (!token || !token.email) {
return res.status(401).json({
success: false,
message: "Unauthorized",
});
}
const user = await prisma.user.findFirst({
where: {
email: token.email,
},
});
if (!user) {
return res.status(404).json({
success: false,
message: "No user found",
});
}
const { id, priceId } = req.body;
if (typeof priceId !== "string") {
return res.status(400).json({
success: false,
message: "No id or priceId found in request",
});
}
const session = await stripe.checkout.sessions.create({
customer: id,
customer_email: user.email,
client_reference_id: String(user.id),
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: "subscription",
allow_promotion_codes: true,
success_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing?canceled=true`,
});
return res.status(200).json({
success: true,
url: session.url,
});
};

View File

@ -1,63 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@documenso/prisma";
import { Subscription } from "@prisma/client";
import { getToken } from "next-auth/jwt";
export type GetSubscriptionRequest = never;
export type GetSubscriptionResponse =
| {
success: false;
message: string;
}
| {
success: true;
subscription: Subscription;
};
export const getSubscriptionHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
if (req.method !== "GET") {
return res.status(405).json({
success: false,
message: "Method not allowed",
});
}
const token = await getToken({
req,
});
if (!token || !token.email) {
return res.status(401).json({
success: false,
message: "Unauthorized",
});
}
const subscription = await prisma.subscription.findFirst({
where: {
User: {
email: token.email,
},
},
});
if (!subscription) {
return res.status(404).json({
success: false,
message: "No subscription found",
});
}
return res.status(200).json({
success: true,
subscription,
});
};

View File

@ -1,54 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripe } from "../client";
export type PortalSessionRequest = {
body: {
id: string;
};
};
export type PortalSessionResponse =
| {
success: false;
message: string;
}
| {
success: true;
url: string;
};
export const portalSessionHandler = async (req: NextApiRequest, res: NextApiResponse<PortalSessionResponse>) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
if (req.method !== "POST") {
return res.status(405).json({
success: false,
message: "Method not allowed",
});
}
const { id } = req.body;
if (typeof id !== "string") {
return res.status(400).json({
success: false,
message: "No id found in request",
});
}
const session = await stripe.billingPortal.sessions.create({
customer: id,
return_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
return res.status(200).json({
success: true,
url: session.url,
});
};

View File

@ -1,164 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "@documenso/prisma";
import { stripe } from "../client";
import { SubscriptionStatus } from "@prisma/client";
import { buffer } from "micro";
import Stripe from "stripe";
const log = (...args: any[]) => console.log("[stripe]", ...args);
export const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
return res.status(500).json({
success: false,
message: "Subscriptions are not enabled",
});
}
const sig =
typeof req.headers["stripe-signature"] === "string" ? req.headers["stripe-signature"] : "";
if (!sig) {
return res.status(400).json({
success: false,
message: "No signature found in request",
});
}
log("constructing body...")
const body = await buffer(req);
log("constructed body")
const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
log("event-type:", event.type);
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
const customerId =
typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
await prisma.subscription.upsert({
where: {
customerId,
},
create: {
customerId,
status: SubscriptionStatus.ACTIVE,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId: Number(session.client_reference_id as string),
},
update: {
customerId,
status: SubscriptionStatus.ACTIVE,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "invoice.payment_succeeded") {
const invoice = event.data.object as Stripe.Invoice;
const customerId =
typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string);
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.ACTIVE,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "invoice.payment_failed") {
const failedInvoice = event.data.object as Stripe.Invoice;
const customerId = failedInvoice.customer as string;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.PAST_DUE,
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "customer.subscription.updated") {
const updatedSubscription = event.data.object as Stripe.Subscription;
const customerId = updatedSubscription.customer as string;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.ACTIVE,
planId: updatedSubscription.id,
priceId: updatedSubscription.items.data[0].price.id,
periodEnd: new Date(updatedSubscription.current_period_end * 1000),
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
if (event.type === "customer.subscription.deleted") {
const deletedSubscription = event.data.object as Stripe.Subscription;
const customerId = deletedSubscription.customer as string;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
return res.status(200).json({
success: true,
message: "Webhook received",
});
}
log("Unhandled webhook event", event.type);
return res.status(400).json({
success: false,
message: "Unhandled webhook event",
});
};

View File

@ -1,6 +0,0 @@
export * from './data/plans'
export * from './fetchers/checkout-session'
export * from './fetchers/get-subscription'
export * from './fetchers/portal-session'
export * from './guards/subscriptions'
export * from './providers/subscription-provider'

View File

@ -1,89 +0,0 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import { fetchSubscription } from "../fetchers/get-subscription";
import { Subscription, SubscriptionStatus } from "@prisma/client";
import { useSession } from "next-auth/react";
export type SubscriptionContextValue = {
subscription: Subscription | null;
hasSubscription: boolean;
isLoading: boolean;
};
const SubscriptionContext = createContext<SubscriptionContextValue>({
subscription: null,
hasSubscription: false,
isLoading: false,
});
export const useSubscription = () => {
const context = useContext(SubscriptionContext);
if (!context) {
throw new Error(`useSubscription must be used within a SubscriptionProvider`);
}
return context;
};
export interface SubscriptionProviderProps {
children: React.ReactNode;
initialSubscription?: Subscription;
}
export const SubscriptionProvider = ({
children,
initialSubscription,
}: SubscriptionProviderProps) => {
const session = useSession();
const [isLoading, setIsLoading] = useState(false);
const [subscription, setSubscription] = useState<Subscription | null>(
initialSubscription || null
);
const hasSubscription = useMemo(() => {
console.log({
"process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS": process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS,
enabled: process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true",
"subscription.status": subscription?.status,
"subscription.periodEnd": subscription?.periodEnd,
});
if (process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true") {
return (
subscription?.status === SubscriptionStatus.ACTIVE &&
!!subscription?.periodEnd &&
new Date(subscription.periodEnd) > new Date()
);
}
return true;
}, [subscription]);
useEffect(() => {
if (process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS === "true" && session.data) {
setIsLoading(true);
fetchSubscription().then((res) => {
if (res.success) {
setSubscription(res.subscription);
} else {
setSubscription(null);
}
setIsLoading(false);
});
}
}, [session.data]);
return (
<SubscriptionContext.Provider
value={{
subscription,
hasSubscription,
isLoading,
}}>
{children}
</SubscriptionContext.Provider>
);
};

View File

@ -8,8 +8,7 @@ export async function insertTextInPDF(
positionX: number,
positionY: number,
page: number = 0,
useHandwritingFont = true,
fontSize = 15
useHandwritingFont = true
): Promise<string> {
const fontBytes = fs.readFileSync("public/fonts/Qwigley-Regular.ttf");
@ -22,7 +21,7 @@ export async function insertTextInPDF(
const pages = pdfDoc.getPages();
const pdfPage = pages[page];
const textSize = useHandwritingFont ? 50 : fontSize;
const textSize = useHandwritingFont ? 50 : 15;
const textWidth = font.widthOfTextAtSize(text, textSize);
const textHeight = font.heightAtSize(textSize);
const fieldSize = { width: 192, height: 64 };

View File

@ -1,2 +0,0 @@
-- AlterEnum
ALTER TYPE "FieldType" ADD VALUE 'NAME';

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "signedAt" TIMESTAMP(3);

View File

@ -1,26 +0,0 @@
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateTable
CREATE TABLE "Subscription" (
"id" SERIAL NOT NULL,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'INACTIVE',
"planId" TEXT,
"priceId" TEXT,
"customerId" TEXT,
"periodEnd" TIMESTAMP(3),
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Subscription_userId_idx" ON "Subscription"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_planId_customerId_key" ON "Subscription"("planId", "customerId");
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,11 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[customerId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "Subscription_planId_customerId_key";
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_customerId_key" ON "Subscription"("customerId");

View File

@ -1,2 +0,0 @@
-- AlterEnum
ALTER TYPE "SubscriptionStatus" ADD VALUE 'PAST_DUE';

View File

@ -3,20 +3,20 @@
"version": "0.0.0",
"private": true,
"main": "index.ts",
"scripts": {
"db-studio": "prisma studio",
"db-seed": "prisma db seed"
},
"dependencies": {
"@prisma/client": "^4.8.1",
"prisma": "^4.8.1"
},
"devDependencies": {
"@types/node": "^18.11.18",
"ts-node": "^10.9.1",
"typescript": "4.8.4"
},
"dependencies": {
"@prisma/client": "^4.8.1",
"prisma": "^4.8.1"
},
"scripts": {
"db-studio": "prisma studio",
"db-seed": "prisma db seed"
},
"prisma": {
"seed": "ts-node --transpile-only ./seed.ts"
}
}
}

View File

@ -23,30 +23,6 @@ model User {
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription[]
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE
INACTIVE
}
model Subscription {
id Int @id @default(autoincrement())
status SubscriptionStatus @default(INACTIVE)
planId String?
priceId String?
customerId String?
periodEnd DateTime?
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([customerId])
@@index([userId])
}
model Account {
@ -116,7 +92,6 @@ model Recipient {
name String @default("") @db.VarChar(255)
token String
expired DateTime?
signedAt DateTime?
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
@ -126,7 +101,6 @@ model Recipient {
}
enum FieldType {
NAME
SIGNATURE
FREE_SIGNATURE
DATE

View File

@ -6,8 +6,8 @@ export function Button(props: any) {
const isLink = typeof props.href !== "undefined" && !props.disabled;
const { color = "primary", icon, disabled, onClick } = props;
const baseStyles =
"inline-flex gap-x-2 items-center justify-center min-w-[80px] rounded-md border border-transparent px-4 py-2 text-sm font-medium shadow-sm disabled:bg-gray-300 duration-200";
const primaryStyles = "text-gray-900 bg-neon hover:bg-neon-dark";
"inline-flex items-center justify-center min-w-[80px] rounded-md border border-transparent px-4 py-2 text-sm font-medium shadow-sm disabled:bg-gray-300";
const primaryStyles = "text-white bg-neon hover:bg-neon-dark";
const secondaryStyles = "border-gray-300 bg-white text-gray-700 hover:bg-gray-50";
return isLink ? (
@ -21,7 +21,7 @@ export function Button(props: any) {
)}
hidden={props.hidden}>
{props.icon ? (
<props.icon className="inline-block h-5 text-inherit" aria-hidden="true"></props.icon>
<props.icon className="mr-1 inline h-5 text-inherit" aria-hidden="true"></props.icon>
) : (
""
)}
@ -40,7 +40,7 @@ export function Button(props: any) {
disabled={props.disabled || props.loading}
hidden={props.hidden}>
{props.icon ? (
<props.icon className="inline h-5 text-inherit" aria-hidden="true"></props.icon>
<props.icon className="mr-1 inline h-5 text-inherit" aria-hidden="true"></props.icon>
) : (
""
)}

View File

@ -1,34 +0,0 @@
import React, { useState } from "react";
import { classNames } from "@documenso/lib";
export function Tooltip(props: any) {
let timeout: NodeJS.Timeout;
const [active, setActive] = useState(false);
const showTip = () => {
timeout = setTimeout(() => {
setActive(true);
}, props.delay || 40);
};
const hideTip = () => {
clearInterval(timeout);
setActive(false);
};
return (
<div className="relative" onPointerEnter={showTip} onPointerLeave={hideTip}>
{props.children}
<div
className={classNames(
"absolute left-1/4 -translate-x-1/2 transform px-4 transition-all delay-50 duration-120",
active && "bottom-9 opacity-100",
!active && "pointer-events-none bottom-6 opacity-0"
)}>
<span className="text-neon-800 bg-neon-200 inline-block rounded py-1 px-2 text-xs">
{props.label}
</span>
</div>
</div>
);
};

View File

@ -1 +0,0 @@
export { Tooltip } from "./Tooltip";

View File

@ -2,4 +2,3 @@ export { Button, IconButton } from "./components/button/index";
export { SelectBox } from "./components/selectBox/index";
export { Breadcrumb } from "./components/breadcrumb/index";
export { Dialog } from "./components/dialog/index";
export { Tooltip } from "./components/tooltip/index";