wip: refresh design

This commit is contained in:
Mythie
2023-06-09 18:21:18 +10:00
parent 76b2fb5edd
commit 159bcade7b
432 changed files with 19640 additions and 29359 deletions

View File

@ -1,36 +0,0 @@
import { Button } from "@documenso/ui";
import Logo from "../components/logo";
import { ArrowSmallLeftIcon } from "@heroicons/react/20/solid";
import Link from "next/link";
export default function Custom404() {
return (
<>
<main className="relative isolate min-h-full bg-gray-100">
<Link href="/" className="absolute top-10 left-10 flex gap-x-2 items-center">
<Logo className="w-10 text-black" />
<h2 className="text-2xl font-semibold">Documenso</h2>
</Link>
<div className="mx-auto max-w-7xl px-6 py-48 text-center sm:py-40 lg:px-8">
<p className="text-brown text-base font-semibold leading-8">404</p>
<h1 className="text-brown mt-4 text-3xl font-bold tracking-tight sm:text-5xl">
Page not found
</h1>
<p className="mt-4 text-base text-gray-700 sm:mt-6">
Sorry, we couldnt find the page youre looking for.
</p>
<div className="mt-10 flex justify-center">
<Button
color="secondary"
href="/"
icon={ArrowSmallLeftIcon}
className="text-brown text-base font-semibold leading-7">
Back to home
</Button>
</div>
</div>
</main>
</>
);
}

View File

@ -1,33 +0,0 @@
import Link from "next/link";
import { Button } from "@documenso/ui";
import Logo from "../components/logo";
import { ArrowSmallLeftIcon } from "@heroicons/react/20/solid";
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
export default function Custom500() {
return (
<>
<div className="relative flex min-h-full flex-col items-center justify-center bg-black text-white">
<Link href="/" className="absolute top-10 left-10 flex items-center gap-x-2 invert">
<Logo className="w-10 text-black" />
<h2 className="text-2xl font-semibold text-black">Documenso</h2>
</Link>
<div className="mt-20 max-w-7xl px-4 py-10">
<p className="inline-flex items-center text-3xl font-bold sm:text-5xl">
500
<span className="relative -top-1.5 px-3 font-thin sm:text-6xl">|</span>{" "}
<span className="align-middle text-base font-semibold sm:text-2xl">
Something went wrong.
</span>
</p>
<div className="mt-10 flex justify-center">
<Button color="secondary" href="/" icon={ArrowSmallLeftIcon}>
Back to home
</Button>
</div>
</div>
</div>
</>
);
}

View File

@ -1,30 +0,0 @@
# Docker config for render.com
# Be sure to add an .env config to your docker web service
FROM node:19.5.0-alpine
RUN apk add --no-cache openjdk11
WORKDIR /app
# Inserted from render.com ENV Group
ARG DATABASE_URL
ARG MAIL_FROM
ARG NEXT_PUBLIC_WEBAPP_URL
ARG NEXTAUTH_SECRET
ARG NEXTAUTH_URL
ARG SENDGRID_API_KEY
# Fill docker ENV variables with render.com ENV Group - BUILD-TIME
ENV DATABASE_URL=$DATABASE_URL \
MAIL_FROM=$ \
NEXT_PUBLIC_WEBAPP_URL=$NEXT_PUBLIC_WEBAPP_URL \
NEXTAUTH_SECRET=&NEXTAUTH_SECRET \
NEXTAUTH_URL=$NEXTAUTH_URL \
SENDGRID_API_KEY=$SENDGRID_API_KEY
COPY . /app
RUN npm run build
# No runtime ENV Variables set so far besides ENV
ENV NODE_ENV production
EXPOSE 3000
CMD ["npm", "start"]

View File

@ -1,52 +0,0 @@
import { ReactElement, ReactNode } from "react";
import { NextPage } from "next";
import type { AppProps } from "next/app";
import { Montserrat, Qwigley } from "next/font/google";
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";
import { SessionProvider } from "next-auth/react";
import { Toaster } from "react-hot-toast";
import "react-tooltip/dist/react-tooltip.css";
export { coloredConsole } from "@documenso/lib";
const montserrat = Montserrat({
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
variable: "--font-sans",
});
const qwigley = Qwigley({
subsets: ["latin"],
weight: ["400"],
display: "swap",
variable: "--font-qwigley",
});
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
export default function App({
Component,
pageProps: { session, initialSubscription, ...pageProps },
}: AppPropsWithLayout) {
const getLayout = Component.getLayout || ((page: any) => page);
return (
<SessionProvider session={session}>
<SubscriptionProvider initialSubscription={initialSubscription}>
<main className={`${montserrat.variable} h-full font-sans`}>
<Toaster position="top-center" />
{getLayout(<Component {...pageProps} />)}
</main>
</SubscriptionProvider>
</SessionProvider>
);
}

View File

@ -1,19 +0,0 @@
import { Head, Html, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html className="h-full scroll-smooth bg-gray-100 font-normal antialiased" lang="en">
<Head>
<meta name="color-scheme"></meta>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
</Head>
<body className="flex h-full flex-col">
<Main />
<NextScript />
</body>
</Html>
);
}

View File

@ -1,91 +0,0 @@
import { ErrorCode } from "@documenso/lib/auth";
import { verifyPassword } from "@documenso/lib/auth";
import prisma from "@documenso/prisma";
import NextAuth, { Session } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import GitHubProvider from "next-auth/providers/github";
export default NextAuth({
secret: process.env.AUTH_SECRET,
pages: {
signIn: "/login",
signOut: "/login",
error: "/auth/error", // Error code passed in query string as ?error=
verifyRequest: "/auth/verify-request", // (used for check email message)
},
providers: [
CredentialsProvider({
id: "credentials",
name: "Documenso.com Login",
type: "credentials",
credentials: {
email: {
label: "Email Address",
type: "email",
placeholder: "john.doe@example.com",
},
password: {
label: "Password",
type: "password",
placeholder: "Select a password. Here is some inspiration: https://xkcd.com/936/",
},
},
async authorize(credentials: any) {
if (!credentials) {
console.error("Credential missing in authorize()");
throw new Error(ErrorCode.InternalServerError);
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email.toLowerCase(),
},
select: {
id: true,
email: true,
password: true,
name: true,
},
});
if (!user) {
throw new Error(ErrorCode.UserNotFound);
}
if (!user.password) {
throw new Error(ErrorCode.UserMissingPassword);
}
const isCorrectPassword = await verifyPassword(credentials.password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectPassword);
}
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
callbacks: {
async jwt({ token, user, account }) {
return {
...token,
};
},
async session({ session, token }) {
const documensoSession: Session = {
...session,
user: {
...session.user,
},
};
documensoSession.expires;
return documensoSession;
},
},
});

View File

@ -1,56 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { hashPassword } from "@documenso/lib/auth";
import { defaultHandler, defaultResponder } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { IdentityProvider } from "@prisma/client";
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const { email, password, source } = req.body;
const cleanEmail = email.toLowerCase();
if (!cleanEmail || !/.+@.+/.test(cleanEmail)) {
res.status(400).json({ message: "Invalid email" });
return;
}
if (!password || password.trim().length < 7) {
return res.status(400).json({
message: "Password should be at least 7 characters long.",
});
}
// User already exists if email already exists
const existingUser = await prisma.user.findFirst({
where: {
email: cleanEmail,
},
});
if (existingUser) {
const message: string = "This email is already registered.";
return res.status(409).json({ message });
}
const hashedPassword = await hashPassword(password);
await prisma.user.upsert({
where: { email: cleanEmail },
update: {
password: hashedPassword,
emailVerified: new Date(Date.now()),
identityProvider: IdentityProvider.DOCUMENSO,
},
create: {
email: cleanEmail,
password: hashedPassword,
identityProvider: IdentityProvider.DOCUMENSO,
source: source,
},
});
res.status(201).end();
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@ -1,95 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getDocument } from "@documenso/lib/query";
import { defaultHandler, defaultResponder, getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { addDigitalSignature } from "@documenso/signing/addDigitalSignature";
import { Document as PrismaDocument } from "@prisma/client";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const { id: documentId } = req.query;
const { token: recipientToken } = req.query;
if (!documentId) {
return res.status(400).send("Missing parameter documentId.");
}
let user = null;
let recipient = null;
if (recipientToken) {
// Request from signing page without login
recipient = await prisma.recipient.findFirst({
where: {
token: recipientToken?.toString(),
},
include: {
Document: { include: { User: true } },
},
});
user = recipient?.Document.User;
} else {
// Request from editor with valid user login
user = await getUserFromToken(req, res);
}
if (!user) return res.status(401).end();
let document: PrismaDocument | null = null;
if (recipientToken) {
document = await prisma.document.findFirst({
where: { id: recipient?.Document?.id },
});
} else {
document = await getDocument(+documentId, req, res);
}
if (!document) res.status(404).end(`No document with id ${documentId} found.`);
const signaturesCount = await prisma.signature.count({
where: {
Field: {
documentId: document?.id,
},
},
});
let signedDocumentAsBase64 = document?.document || "";
// No need to add a signature, if no one signed yet.
if (signaturesCount > 0) {
signedDocumentAsBase64 = await addDigitalSignature(document?.document || "");
}
const buffer: Buffer = Buffer.from(signedDocumentAsBase64, "base64");
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Length", buffer.length);
res.setHeader("Content-Disposition", `attachment; filename=${document?.title}`);
return res.status(200).send(buffer);
}
async function deleteHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
const { id: documentId } = req.query;
if (!user) return;
if (!documentId) {
res.status(400).send("Missing parameter documentId.");
return;
}
await prisma.document
.delete({
where: {
id: +documentId,
},
})
.then(() => {
res.status(200).end();
});
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
DELETE: Promise.resolve({ default: defaultResponder(deleteHandler) }),
});

View File

@ -1,32 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getDocument } from "@documenso/lib/query";
import { defaultHandler, defaultResponder, getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { FieldType, Document as PrismaDocument } from "@prisma/client";
import short from "short-uuid";
async function deleteHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
const { fid: fieldId } = req.query;
const body: {
id: number;
type: FieldType;
page: number;
position: { x: number; y: number };
} = req.body;
if (!user) return;
if (!fieldId) {
res.status(400).send("Missing parameter fieldId.");
return;
}
await prisma.field.delete({ where: { id: +fieldId } });
return res.status(200).end();
}
export default defaultHandler({
DELETE: Promise.resolve({ default: defaultResponder(deleteHandler) }),
});

View File

@ -1,101 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getDocument } from "@documenso/lib/query";
import { defaultHandler, defaultResponder, getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { FieldType, Document as PrismaDocument } from "@prisma/client";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
const { id: documentId } = req.query;
const body: {
id: number;
type: FieldType;
page: number;
position: { x: number; y: number };
} = req.body;
if (!user) return;
if (!documentId) {
res.status(400).send("Missing parameter documentId.");
return;
}
// todo entity ownerships checks
const fields = await prisma.field.findMany({
where: { documentId: +documentId },
include: { Recipient: true },
});
return res.status(200).end(JSON.stringify(fields));
}
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const { token: recipientToken } = req.query;
let user = null;
if (!recipientToken) user = await getUserFromToken(req, res);
if (!user && !recipientToken) return res.status(401).end();
const body: {
id: number;
type: FieldType;
page: number;
positionX: number;
positionY: number;
Recipient: { id: number };
customText: string;
} = req.body;
const { id: documentId } = req.query;
if (!documentId) {
return res.status(400).send("Missing parameter documentId.");
}
if (recipientToken) {
const recipient = await prisma.recipient.findFirst({
where: { token: recipientToken?.toString() },
});
if (!recipient || recipient?.documentId !== +documentId)
return res.status(401).send("Recipient does not have access to this document.");
}
if (user) {
const document: PrismaDocument = await getDocument(+documentId, req, res);
// todo entity ownerships checks
if (document.userId !== user.id) {
return res.status(401).send("User does not have access to this document.");
}
}
const field = await prisma.field.upsert({
where: {
id: +body.id,
},
update: {
positionX: +body.positionX,
positionY: +body.positionY,
customText: body.customText,
},
create: {
documentId: +documentId,
type: body.type,
page: +body.page,
inserted: false,
positionX: +body.positionX,
positionY: +body.positionY,
customText: body.customText,
recipientId: body.Recipient.id,
},
include: {
Recipient: true,
},
});
return res.status(201).end(JSON.stringify(field));
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@ -1,29 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getDocument } from "@documenso/lib/query";
import { defaultHandler, defaultResponder, getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { Document as PrismaDocument } from "@prisma/client";
import short from "short-uuid";
async function deleteHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
const { id: documentId, rid: recipientId } = req.query;
const body = req.body;
if (!recipientId) {
res.status(400).send("Missing parameter recipientId.");
return;
}
await prisma.recipient.delete({
where: {
id: +recipientId,
},
});
return res.status(200).end();
}
export default defaultHandler({
DELETE: Promise.resolve({ default: defaultResponder(deleteHandler) }),
});

View File

@ -1,48 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getDocument } from "@documenso/lib/query";
import { defaultHandler, defaultResponder, getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { Document as PrismaDocument } from "@prisma/client";
import short from "short-uuid";
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
const { id: documentId } = req.query;
const body: { name: string; email: string; id: string } = req.body;
if (!user) return;
if (!documentId) {
res.status(400).send("Missing parameter documentId.");
return;
}
const document: PrismaDocument = await getDocument(+documentId, req, res);
// todo entity ownerships checks
if (document.userId !== user.id) {
return res.status(401).send("User does not have access to this document.");
}
const recipient = await prisma.recipient.upsert({
where: {
id: +body.id,
},
update: {
email: body.email.toString(),
name: body.name.toString(),
},
create: {
documentId: +documentId,
email: body.email.toString(),
name: body.name.toString(),
token: short.generate().toString(),
},
});
return res.status(200).end(JSON.stringify(recipient));
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@ -1,72 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { sendSigningRequest } from "@documenso/lib/mail";
import { getDocument } from "@documenso/lib/query";
import { defaultHandler, defaultResponder, getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { Document as PrismaDocument, SendStatus } from "@prisma/client";
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
try {
const user = await getUserFromToken(req, res);
const { id: documentId } = req.query;
const { resendTo: resendTo = [] } = req.body;
if (!user) {
return res.status(401).send("Unauthorized");
}
if (!documentId) {
return res.status(400).send("Missing parameter documentId.");
}
const document: PrismaDocument = await getDocument(+documentId, req, res);
if (!document) {
res.status(404).end(`No document with id ${documentId} found.`);
}
let recipientCondition: any = {
documentId: +documentId,
sendStatus: SendStatus.NOT_SENT,
};
if (resendTo.length) {
recipientCondition = {
documentId: +documentId,
id: { in: resendTo },
};
}
const recipients = await prisma.recipient.findMany({
where: {
...recipientCondition,
},
});
if (!recipients.length) {
return res.status(200).send(recipients.length);
}
let sentRequests = 0;
await Promise.all(
recipients.map(async (recipient) => {
await sendSigningRequest(recipient, document, user);
sentRequests++;
})
);
if (sentRequests === recipients.length) {
return res.status(200).send(recipients.length);
}
return res.status(502).end("Could not send request for signing.");
} catch (err) {
return res.status(502).end("Could not send request for signing.");
}
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@ -1,191 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { sendSigningDoneMail } from "@documenso/lib/mail";
import { getDocument } from "@documenso/lib/query";
import { defaultHandler, defaultResponder } from "@documenso/lib/server";
import { insertImageInPDF, insertTextInPDF } from "@documenso/pdf";
import prisma from "@documenso/prisma";
import { DocumentStatus, SigningStatus } from "@prisma/client";
import { FieldType, Document as PrismaDocument } from "@prisma/client";
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const { token: recipientToken } = req.query;
const { signatures: signaturesFromBody }: { signatures: any[] } = req.body;
if (!recipientToken) {
return res.status(401).send("Missing recipient token.");
}
// The recipient who received the signing request
const recipient = await prisma.recipient.findFirstOrThrow({
where: { token: recipientToken?.toString() },
});
if (!recipient) {
return res.status(401).send("Recipient not found.");
}
const document: PrismaDocument = await prisma.document.findFirstOrThrow({
where: {
id: recipient.documentId,
},
include: {
Recipient: {
orderBy: {
id: "asc",
},
},
Field: { include: { Recipient: true, Signature: true } },
},
});
if (!document) res.status(404).end(`No document found.`);
let documentWithInserts = document.document;
for (const signature of signaturesFromBody) {
if (!signature.signatureImage && !signature.typedSignature) {
documentWithInserts = document.document;
throw new Error("Can't save invalid signature.");
}
await saveSignature(signature);
const signedField = await prisma.field.findFirstOrThrow({
where: { id: signature.fieldId },
include: { Signature: true },
});
await insertSignatureInDocument(signedField);
}
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
});
const unsignedRecipients = await prisma.recipient.findMany({
where: {
documentId: recipient.documentId,
signingStatus: SigningStatus.NOT_SIGNED,
},
});
const signedRecipients = await prisma.recipient.findMany({
where: {
documentId: recipient.documentId,
signingStatus: SigningStatus.SIGNED,
},
});
// Don't check for inserted, because currently no "sign again" scenarios exist and
// this is probably the expected behaviour in unclean states.
const nonSignatureFields = await prisma.field.findMany({
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
for (const field of nonSignatureFields) {
documentWithInserts = await insertTextInPDF(
documentWithInserts,
field.type === FieldType.DATE
? new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}).format(field.Recipient?.signedAt ?? new Date())
: field.customText || "",
field.positionX,
field.positionY,
field.page,
false
);
await prisma.field.update({
where: {
id: field.id,
},
data: {
inserted: true,
},
});
}
await prisma.document.update({
where: {
id: recipient.documentId,
},
data: {
document: documentWithInserts,
status: unsignedRecipients.length > 0 ? DocumentStatus.PENDING : DocumentStatus.COMPLETED,
},
});
if (unsignedRecipients.length === 0) {
const documentOwner = await prisma.user.findFirstOrThrow({
where: { id: document.userId },
select: { email: true, name: true },
});
document.document = documentWithInserts;
if (documentOwner) await sendSigningDoneMail(document, documentOwner);
for (const signer of signedRecipients) {
await sendSigningDoneMail(document, signer);
}
}
return res.status(200).end();
async function insertSignatureInDocument(signedField: any) {
if (signedField?.Signature?.signatureImageAsBase64) {
documentWithInserts = await insertImageInPDF(
documentWithInserts,
signedField.Signature ? signedField.Signature?.signatureImageAsBase64 : "",
signedField.positionX,
signedField.positionY,
signedField.page
);
} else if (signedField?.Signature?.typedSignature) {
documentWithInserts = await insertTextInPDF(
documentWithInserts,
signedField.Signature.typedSignature,
signedField.positionX,
signedField.positionY,
signedField.page
);
} else {
documentWithInserts = document.document;
throw new Error("Invalid signature could not be inserted.");
}
}
async function saveSignature(signature: any) {
await prisma.signature.upsert({
where: {
fieldId: signature.fieldId,
},
update: {},
create: {
recipientId: recipient.id,
fieldId: signature.fieldId,
signatureImageAsBase64: signature.signatureImage ? signature.signatureImage : null,
typedSignature: signature.typedSignature ? signature.typedSignature : null,
},
});
}
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@ -1,70 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getDocumentsForUserFromToken } from "@documenso/lib/query";
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: {
bodyParser: false,
},
};
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.");
}
form.parse(req, async (err, fields, files) => {
if (err) throw err;
const uploadedDocument: any = files["document"];
const title = uploadedDocument[0].originalFilename;
const path = uploadedDocument[0].filepath;
const fs = require("fs");
const buffer = fs.readFileSync(path);
const documentAsBase64EncodedString = buffer.toString("base64");
await prisma
.$transaction([
prisma.document.create({
data: {
title: title,
userId: user?.id,
document: documentAsBase64EncodedString,
},
}),
])
.then((document) => {
return res.status(201).send(document[0].id);
})
.catch((err) => {
throw err;
});
});
}
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
if (!user) return;
const documents = await getDocumentsForUserFromToken({ req: req, res: res });
return res.status(200).json(documents);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@ -1,15 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { defaultHandler, defaultResponder } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
// Return a healthy 200 status code for uptime monitoring and render.com zero-downtime-deploy
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
// Some generic database access to make sure the service is healthy.
const users = await prisma.user.findFirst();
return res.status(200).json({ message: "Api up and running :)" });
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});

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,23 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getDocument } from "@documenso/lib/query";
import { defaultHandler, defaultResponder, getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
import { addDigitalSignature } from "@documenso/signing/addDigitalSignature";
import { Document as PrismaDocument } from "@prisma/client";
// todo remove before launch
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const documentId = req.query.id || 1;
const document: PrismaDocument = await getDocument(+documentId, req, res);
const signedDocument = await addDigitalSignature(document.document);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Length", signedDocument.length);
res.setHeader("Content-Disposition", `attachment; filename=${document.title}`);
return res.status(200).send(signedDocument);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});

View File

@ -1,53 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { hashPassword } from "@documenso/lib/auth";
import { defaultHandler, defaultResponder, getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const { method, body } = req;
if (!body.email) {
return res.status(400).json({ message: "Email cannot be empty." });
}
let newUser: any;
newUser = await prisma.user
.create({
data: { email: body.email },
})
.then(async () => {
return res.status(201).send(newUser);
});
}
async function patchHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
if (!user) return;
const updatedUser = req.body;
let password: string | undefined = undefined;
if (typeof updatedUser.password === "string" && updatedUser.password.length >= 6) {
password = await hashPassword(updatedUser.password);
}
await prisma.user
.update({
where: {
id: user.id,
},
data: {
name: updatedUser.name,
password,
},
})
.then(() => {
return res.status(200).end();
});
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
PATCH: Promise.resolve({ default: defaultResponder(patchHandler) }),
});

View File

@ -1,23 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { defaultHandler, defaultResponder, getUserFromToken } from "@documenso/lib/server";
import prisma from "@documenso/prisma";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const user = await getUserFromToken(req, res);
if (!user) return;
return prisma.user.findFirstOrThrow({
where: {
id: user.id,
},
select: {
email: true,
name: true,
},
});
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});

View File

@ -1,177 +0,0 @@
import { ChangeEvent, ReactElement } from "react";
import Head from "next/head";
import Link from "next/link";
import { uploadDocument } from "@documenso/features";
import { getDocumentsForUserFromToken } from "@documenso/lib/query";
import { getUserFromToken } from "@documenso/lib/server";
import Layout from "../components/layout";
import type { NextPageWithLayout } from "./_app";
import {
CheckBadgeIcon,
DocumentIcon,
ExclamationTriangleIcon,
UsersIcon,
} from "@heroicons/react/24/outline";
import {
DocumentStatus,
Document as PrismaDocument,
SendStatus,
SigningStatus,
} 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",
stat: "0",
icon: DocumentIcon,
link: "/documents?filter=DRAFT",
},
{
name: "Waiting for others",
stat: "0",
icon: UsersIcon,
link: "/documents?filter=PENDING",
},
{
name: "Completed",
stat: "0",
icon: CheckBadgeIcon,
link: "/documents?filter=COMPLETED",
},
];
return (
<>
<Head>
<title>Dashboard | Documenso</title>
</Head>
<div className="py-10 max-sm:px-4">
<header>
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
Dashboard
</h1>
</header>
<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 ">
<item.icon
className="text-neon-600 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">
{getStat(item.name, props)}
</dd>
</div>
</Link>
))}
</dl>
<div className="mt-12">
<input
id="fileUploadHelper"
type="file"
accept="application/pdf"
onChange={(event: ChangeEvent) => {
uploadDocument(event);
}}
hidden
/>
</div>
<div
onClick={() => {
if (hasSubscription) {
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">
<svg
className="mx-auto h-12 w-12 text-gray-400 group-hover:text-gray-700 duration-200"
stroke="currentColor"
fill="none"
viewBox="0 00 20 25"
aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
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">
Add a new PDF document.
</span>
</div>
<ReactTooltip
anchorId="add_document"
place="bottom"
content="No preparation needed. Any PDF will do."
/>
</div>
</>
);
};
DashboardPage.getLayout = function getLayout(page: ReactElement) {
return <Layout>{page}</Layout>;
};
function getStat(name: string, props: any) {
if (name === "Draft") return props.dashboard.drafts;
if (name === "Waiting for others") return props.dashboard.waiting;
if (name === "Completed") return props.dashboard.completed;
return 0;
}
export async function getServerSideProps(context: any) {
const user = await getUserFromToken(context.req, context.res);
if (!user)
return {
redirect: {
destination: "/login",
permanent: false,
},
};
const documents: any[] = await getDocumentsForUserFromToken(context);
const drafts: PrismaDocument[] = documents.filter((d) => d.status === DocumentStatus.DRAFT);
const waiting: any[] = documents.filter(
(e) =>
e.Recipient.length > 0 &&
e.Recipient.some((r: any) => r.sendStatus === SendStatus.SENT) &&
e.Recipient.some((r: any) => r.signingStatus === SigningStatus.NOT_SIGNED)
);
const completed: PrismaDocument[] = documents.filter(
(d) => d.status === DocumentStatus.COMPLETED
);
return {
props: {
dashboard: {
drafts: drafts.length,
waiting: waiting.length,
completed: completed.length,
},
},
};
}
export default DashboardPage;

View File

@ -1,437 +0,0 @@
import { ReactElement, useEffect, useState } from "react";
import { NextPageContext } from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import { uploadDocument } from "@documenso/features";
import { deleteDocument, getDocuments } from "@documenso/lib/api";
import { useSubscription } from "@documenso/lib/stripe";
import { Button, IconButton, SelectBox } from "@documenso/ui";
import Layout from "../components/layout";
import type { NextPageWithLayout } from "./_app";
import {
ArrowDownTrayIcon,
CheckBadgeIcon,
CheckIcon,
DocumentPlusIcon,
EnvelopeIcon,
FunnelIcon,
PencilSquareIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { DocumentStatus } from "@prisma/client";
import { Tooltip as ReactTooltip } from "react-tooltip";
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[] = [
{ label: "All", value: "ALL" },
{ label: "Draft", value: "DRAFT" },
{ label: "Waiting for others", value: "PENDING" },
{ label: "Completed", value: "COMPLETED" },
];
const createdFilter = [
{ label: "All Time", value: -1 },
{ label: "Last 24 hours", value: 1 },
{ label: "Last 7 days", value: 7 },
{ label: "Last 30 days", value: 30 },
{ label: "Last 3 months", value: 90 },
{ label: "Last 12 months", value: 366 },
];
const [selectedStatusFilter, setSelectedStatusFilter] = useState(statusFilters[0]);
const [selectedCreatedFilter, setSelectedCreatedFilter] = useState(createdFilter[0]);
const loadDocuments = async () => {
if (!documents.length) setLoading(true);
getDocuments().then((res: any) => {
res.json().then((j: any) => {
setDocuments(j);
setLoading(false);
});
});
};
useEffect(() => {
loadDocuments().finally(() => {
setSelectedStatusFilter(
statusFilters.filter((status) => status.value === props.filter.toUpperCase())[0]
);
});
}, []);
useEffect(() => {
setFilteredDocuments(filterDocumentes(documents));
}, [documents, selectedStatusFilter, selectedCreatedFilter]);
function showDocument(documentId: number) {
router.push(`/documents/${documentId}/recipients`);
}
function filterDocumentes(documents: []): any {
let filteredDocuments = documents.filter(
(d: any) => d.status === selectedStatusFilter.value || selectedStatusFilter.value === "ALL"
);
filteredDocuments = filteredDocuments.filter((document: any) =>
wasXDaysAgoOrLess(new Date(document.created), selectedCreatedFilter.value)
);
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;
const millisecondsInDay = 24 * 60 * 60 * 1000; // Number of milliseconds in a day
const today: Date = new Date(); // Today's date
// Calculate the difference between the two dates in days
const diffInDays = Math.floor((today.getTime() - documentDate.getTime()) / millisecondsInDay);
console.log(diffInDays);
// Check if the difference is letss or equal to lastXDays
return diffInDays <= lastXDays;
}
return (
<>
<Head>
<title>Documents | Documenso</title>
</Head>
<div className="px-4 sm:px-6 lg:px-8">
<div className="mt-10 sm:flex sm:items-center">
<div className="sm:flex-auto">
<header>
<h1 className="text-3xl font-bold leading-tight tracking-tight text-gray-900">
Documents
</h1>
</header>
</div>
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<Button
icon={DocumentPlusIcon}
disabled={!hasSubscription}
onClick={() => {
document?.getElementById("fileUploadHelper")?.click();
}}>
Add Document
</Button>
</div>
</div>
<div className="mt-3 mb-12 flex flex-wrap items-center justify-start gap-x-4 md:justify-end gap-y-4">
<SelectBox
className="block flex-1 md:flex-none md:w-1/4"
label="Status"
options={statusFilters}
value={selectedStatusFilter}
onChange={handleStatusFilterChange}
/>
<SelectBox
className="block flex-1 md:flex-none md:w-1/4"
label="Created"
options={createdFilter}
value={selectedCreatedFilter}
onChange={setSelectedCreatedFilter}
/>
<div className="block w-fit pt-5">
{filteredDocuments.length != 1 ? filteredDocuments.length + " Documents" : "1 Document"}
</div>
</div>
<div className="mt-8 max-w-[1100px]" hidden={!loading}>
<div className="ph-item">
<div className="ph-col-12">
<div className="ph-picture"></div>
<div className="ph-row">
<div className="ph-col-6 big"></div>
<div className="ph-col-4 empty big"></div>
<div className="ph-col-2 big"></div>
<div className="ph-col-4"></div>
<div className="ph-col-8 empty"></div>
<div className="ph-col-6"></div>
<div className="ph-col-6 empty"></div>
<div className="ph-col-12"></div>
</div>
</div>
</div>
</div>
<div className="mt-8 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}>
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Title
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Recipients
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Status
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Created
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span className="sr-only">Delete</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{filteredDocuments.map((document: any, index: number) => (
<tr
key={document.id}
className="cursor-pointer hover:bg-gray-100"
onClick={(event) => showDocument(document.id)}>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{document.title || "#" + document.id}
</td>
<td className="inline-flex max-w-[250px] flex-wrap gap-x-2 gap-y-1 whitespace-nowrap py-3 text-sm text-gray-500">
{document.Recipient.map((item: any) => (
<div key={item.id}>
{item.sendStatus === "NOT_SENT" ? (
<span
id="sent_icon"
className="inline-flex h-6 flex-shrink-0 items-center 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>
) : (
""
)}
{item.sendStatus === "SENT" && item.readStatus !== "OPENED" ? (
<span id="sent_icon">
<span
id="sent_icon"
className="inline-flex h-6 flex-shrink-0 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>
{item.name ? item.name + " <" + item.email + ">" : item.email}
</span>
</span>
) : (
""
)}
{item.readStatus === "OPENED" &&
item.signingStatus === "NOT_SIGNED" ? (
<span id="read_icon">
<span
id="sent_icon"
className="inline-flex h-6 flex-shrink-0 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>
{item.name ? item.name + " <" + item.email + ">" : item.email}
</span>
</span>
) : (
""
)}
{item.signingStatus === "SIGNED" ? (
<span id="signed_icon">
<span className="inline-flex h-6 flex-shrink-0 items-center 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>
</span>
) : (
""
)}
</div>
))}
{document.Recipient.length === 0 ? "-" : null}
<ReactTooltip
anchorId="sent_icon"
place="bottom"
content="Document was sent to recipient."
/>
<ReactTooltip
anchorId="read_icon"
place="bottom"
content="Document was opened but not signed yet."
/>
<ReactTooltip
anchorId="signed_icon"
place="bottom"
content="Document was signed by the recipient."
/>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{formatDocumentStatus(document.status)}
<p>
<small hidden={document.Recipient.length === 0}>
{document.Recipient.filter((r: any) => r.signingStatus === "SIGNED")
.length || 0}
/{document.Recipient.length || 0}
</small>
</p>
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{new Date(document.created).toLocaleDateString()}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<div>
<IconButton
icon={PencilSquareIcon}
className="mr-2"
onClick={(event: any) => {
event.preventDefault();
event.stopPropagation();
router.push("/documents/" + document.id);
}}
disabled={document.status === "COMPLETED"}
/>
<IconButton
icon={ArrowDownTrayIcon}
className="mr-2"
onClick={(event: any) => {
event.preventDefault();
event.stopPropagation();
router.push("/api/documents/" + document.id);
}}
/>
<IconButton
icon={TrashIcon}
onClick={(event: any) => {
event.preventDefault();
event.stopPropagation();
if (confirm("Are you sure you want to delete this document")) {
const documentsWithoutIndex = [...documents];
const removedItem: any = documentsWithoutIndex.splice(index, 1);
setDocuments(documentsWithoutIndex);
deleteDocument(document.id)
.catch((err) => {
documentsWithoutIndex.splice(index, 0, removedItem);
setDocuments(documentsWithoutIndex);
})
.then(() => {
loadDocuments();
});
}
}}></IconButton>
<span className="sr-only">, {document.name}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div hidden={filteredDocuments.length > 0} className="mx-auto mt-12 w-fit p-3">
<FunnelIcon className="mr-1 inline w-5 align-middle" /> Nothing here. Maybe try a
different filter.
</div>
</div>
</div>
</div>
</div>
<div className="mt-24 text-center" id="empty" hidden={documents.length > 0 || loading}>
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
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>
<h3 className="mt-2 text-sm font-medium text-gray-900">No documents</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by adding a document. Any PDF will do.
</p>
<div className="mt-6">
<Button
icon={PlusIcon}
disabled={!hasSubscription}
onClick={() => {
document?.getElementById("fileUploadHelper")?.click();
}}>
Add Document
</Button>
<input
id="fileUploadHelper"
type="file"
accept="application/pdf"
onChange={(event: any) => {
uploadDocument(event);
}}
hidden
/>
</div>
</div>
<ReactTooltip
anchorId="empty"
place="bottom"
content="No preparation needed. Any PDF will do."
/>
</>
);
};
function formatDocumentStatus(status: DocumentStatus) {
switch (status) {
case DocumentStatus.DRAFT:
return "Draft";
case DocumentStatus.PENDING:
return "Waiting for others";
case DocumentStatus.COMPLETED:
return "Completed";
}
}
export async function getServerSideProps(context: NextPageContext) {
const filter = context.query["filter"];
return {
props: {
filter: filter || "ALL",
},
};
}
DocumentsPage.getLayout = function getLayout(page: ReactElement) {
return <Layout>{page}</Layout>;
};
export default DocumentsPage;

View File

@ -1,130 +0,0 @@
import { ReactElement } from "react";
import Link from "next/link";
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";
import { NextPageWithLayout } from "../../_app";
import { InformationCircleIcon, PaperAirplaneIcon, UsersIcon } from "@heroicons/react/24/outline";
import { DocumentStatus } from "@prisma/client";
import { Document as PrismaDocument } from "@prisma/client";
const DocumentsDetailPage: NextPageWithLayout = (props: any) => {
const router = useRouter();
const { hasSubscription } = useSubscription();
return (
<div className="mt-4">
<div>
<div>
<Breadcrumb
document={props.document}
items={[
{
title: "Documents",
href: "/documents",
},
{
title: props.document.title,
href: NEXT_PUBLIC_WEBAPP_URL + "/documents/" + props.document.id,
},
]}
/>
</div>
<div className="mt-2 md:flex md:items-center md:justify-between">
<div className="min-w-0 flex-1">
<h2 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{props.document.title}
</h2>
<div className="mt-1 flex flex-col sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-6">
<div className="mt-2 flex items-center text-sm text-gray-500">
<UsersIcon
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
aria-hidden="true"
/>
<Link href={`/documents/${props.document.id}/recipients`}>
{props?.document?.Recipient?.length} Recipients
</Link>
</div>
<div className="mt-2 flex items-center text-sm text-gray-500">
<InformationCircleIcon
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
aria-hidden="true"
/>
{formatDocumentStatus(props.document.status)}
</div>
</div>
</div>
<div className="mt-4 flex flex-shrink-0 md:mt-0 md:ml-4">
<Button
icon={PaperAirplaneIcon}
className="ml-3"
href={NEXT_PUBLIC_WEBAPP_URL + "/documents/" + props.document.id + "/recipients"}
onClick={() => {
if (
confirm(`Send document out to ${props?.document?.Recipient?.length} recipients?`)
) {
}
}}>
Prepare to Send
</Button>
</div>
</div>
</div>
<div className="mx-auto w-fit">
<PDFEditor document={props.document} />
</div>
</div>
);
};
function formatDocumentStatus(status: DocumentStatus) {
switch (status) {
case DocumentStatus.DRAFT:
return "Draft";
case DocumentStatus.PENDING:
return "Waiting for others";
case DocumentStatus.COMPLETED:
return "Completed";
}
}
export async function getServerSideProps(context: any) {
const user = await getUserFromToken(context.req, context.res);
if (!user)
return {
redirect: {
destination: "/login",
permanent: false,
},
};
const { id: documentId } = context.query;
try {
const document: PrismaDocument = await getDocument(+documentId, context.req, context.res);
return {
props: {
document: JSON.parse(JSON.stringify({ ...document, document: "" })),
},
};
} catch (error) {
return {
notFound: true,
};
}
}
DocumentsDetailPage.getLayout = function getLayout(page: ReactElement) {
return <Layout>{page}</Layout>;
};
export default DocumentsDetailPage;

View File

@ -1,376 +0,0 @@
import { ReactElement, useRef, useState } from "react";
import Head from "next/head";
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 Layout from "../../../components/layout";
import { NextPageWithLayout } from "../../_app";
import {
ArrowDownTrayIcon,
CheckBadgeIcon,
CheckIcon,
EnvelopeIcon,
PaperAirplaneIcon,
PencilSquareIcon,
TrashIcon,
UserPlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
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'>>;
};
type FormSigner = FormValues["signers"][number];
const RecipientsPage: NextPageWithLayout = (props: any) => {
const { hasSubscription } = useSubscription();
const title: string = `"` + props?.document?.title + `"` + "Recipients | Documenso";
const breadcrumbItems = [
{
title: "Documents",
href: "/documents",
},
{
title: props.document.title,
href:
props.document.status !== DocumentStatus.COMPLETED
? NEXT_PUBLIC_WEBAPP_URL + "/documents/" + props.document.id
: NEXT_PUBLIC_WEBAPP_URL + "/documents/" + props.document.id + "/recipients",
},
{
title: "Recipients",
href: NEXT_PUBLIC_WEBAPP_URL + "/documents/" + props.document.id + "/recipients",
},
];
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const form = useForm<FormValues>({
defaultValues: { signers: props?.document?.Recipient },
});
const {
register,
trigger,
control,
formState: { errors },
} = form;
const { fields, append, remove } = useFieldArray({
keyName: "dieldArrayId",
name: "signers",
control,
});
const formValues = useWatch({ control, name: "signers" });
const cancelButtonRef = useRef(null);
const hasEmailError = (formValue: FormSigner): boolean => {
const index = formValues.findIndex((e) => e.id === formValue.id);
return !!errors?.signers?.[index]?.email;
};
return (
<>
<Head>
<title>{title}</title>
</Head>
<div className="mt-10 px-6 sm:px-0">
<div>
<Breadcrumb document={props.document} items={breadcrumbItems} />
</div>
<div className="mt-2 md:flex md:items-center md:justify-between">
<div className="min-w-0 flex-1">
<h2 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{props.document.title}
</h2>
</div>
<div className="mt-4 flex flex-shrink-0 md:mt-0 md:ml-4">
<Button
icon={ArrowDownTrayIcon}
color="secondary"
className="mr-2"
href={"/api/documents/" + props.document.id}>
Download
</Button>
{props.document.status !== DocumentStatus.COMPLETED && (
<>
<Button
icon={PencilSquareIcon}
disabled={props.document.status === DocumentStatus.COMPLETED}
color={
props.document.status === DocumentStatus.COMPLETED ? "primary" : "secondary"
}
className="mr-2"
href={breadcrumbItems[1].href}>
Edit Document
</Button>
<Button
className="min-w-[125px]"
color="primary"
icon={PaperAirplaneIcon}
onClick={() => {
formValues.some((r) => r.email && hasEmailError(r))
? toast.error("Please enter a valid email address.", { id: "invalid email" })
: setOpen(true);
}}
disabled={
!hasSubscription ||
(formValues.length || 0) === 0 ||
!formValues.some(
(r) => r.email && !hasEmailError(r) && r.sendStatus === "NOT_SENT"
) ||
loading
}>
Send
</Button>
</>
)}
</div>
</div>
<div className="mt-10 overflow-hidden rounded-md bg-white p-4 shadow sm:p-6">
<div className="border-b border-gray-200 pb-3 sm:pb-5">
<h3 className="text-lg font-medium leading-6 text-gray-900 ">Signers</h3>
<p className="mt-2 max-w-4xl text-sm text-gray-500">
{props.document.status !== DocumentStatus.COMPLETED
? "The people who will sign the document."
: "The people who signed the document."}
</p>
</div>
<FormProvider {...form}>
<form
onChange={() => {
trigger();
}}>
<ul role="list" className="divide-y divide-gray-200">
{fields.map((item, index) => (
<li
key={index}
className="group w-full border-0 px-2 py-3 hover:bg-green-50 sm:py-4">
<div id="container" className="block w-full lg:flex lg:justify-between">
<div className="block space-y-2 md:flex md:space-x-2 md:space-y-0">
<div
className={classNames(
"focus-within:border-neon focus-within:ring-neon rounded-md border border-gray-300 px-3 py-2 shadow-sm focus-within:ring-1 md:w-[250px]",
item.sendStatus === "SENT" ? "bg-gray-100" : ""
)}>
<label htmlFor="name" className="block text-xs font-medium text-gray-900">
Email
</label>
<input
type="email"
{...register(`signers.${index}.email`, {
pattern: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
})}
defaultValue={item.email}
disabled={item.sendStatus === "SENT" || loading}
onBlur={() => {
if (!errors?.signers?.[index])
createOrUpdateRecipient({
...formValues[index],
documentId: props.document.id,
});
}}
onKeyDown={(event: any) => {
if (event.key === "Enter")
if (!errors?.signers?.[index])
createOrUpdateRecipient({
...formValues[index],
documentId: props.document.id,
});
}}
className="block w-full border-0 bg-inherit p-0 text-gray-900 placeholder-gray-500 outline-none disabled:bg-neutral-100 sm:text-sm"
/>
{errors?.signers?.[index] ? (
<p className="mt-2 text-sm text-red-600" id="email-error">
<XMarkIcon className="inline h-5" /> Invalid Email
</p>
) : (
""
)}
</div>
<div
className={classNames(
"focus-within:border-neon focus-within:ring-neon rounded-md border border-gray-300 px-3 py-2 shadow-sm focus-within:ring-1 md:w-[250px]",
item.sendStatus === "SENT" ? "bg-gray-100" : ""
)}>
<label htmlFor="name" className="block text-xs font-medium text-gray-900">
Name (optional)
</label>
<input
type="text"
{...register(`signers.${index}.name`)}
defaultValue={item.name}
disabled={item.sendStatus === "SENT" || loading}
onBlur={() => {
if (!errors?.signers?.[index])
createOrUpdateRecipient({
...formValues[index],
documentId: props.document.id,
});
}}
onKeyDown={(event: any) => {
if (event.key === "Enter" && !errors?.signers?.[index])
createOrUpdateRecipient({
...formValues[index],
documentId: props.document.id,
});
}}
className="block w-full border-0 bg-inherit p-0 text-gray-900 placeholder-gray-500 outline-none disabled:bg-neutral-100 sm:text-sm"
/>
</div>
</div>
<div className="flex items-center space-x-2 lg:ml-2">
<div className="mb-2 mr-2 flex lg:mr-0">
<div key={item.id} className="space-x-2">
{item.sendStatus === "NOT_SENT" ? (
<span
id="sent_icon"
className="mt-3 inline-block flex-shrink-0 rounded-full bg-yellow-200 px-2 py-0.5 text-xs font-medium text-gray-800">
Not Sent
</span>
) : null}
{item.sendStatus === "SENT" && item.readStatus !== "OPENED" ? (
<span id="sent_icon">
<span
id="sent_icon"
className="mt-3 inline-block flex-shrink-0 rounded-full bg-yellow-200 px-2 py-0.5 text-xs font-medium text-gray-800 ">
<CheckIcon className="mr-1 inline h-5" /> Sent
</span>
</span>
) : null}
{item.readStatus === "OPENED" && item.signingStatus === "NOT_SIGNED" ? (
<span id="read_icon">
<span
id="sent_icon"
className="mt-3 inline-block flex-shrink-0 rounded-full bg-yellow-200 px-2 py-0.5 text-xs font-medium text-gray-800">
<CheckIcon className="-mr-2 inline h-5"></CheckIcon>
<CheckIcon className="mr-1 inline h-5"></CheckIcon>
Seen
</span>
</span>
) : null}
{item.signingStatus === "SIGNED" ? (
<span id="signed_icon">
<span
id="sent_icon"
className="mt-3 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>
Signed
</span>
</span>
) : null}
</div>
</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
}
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>
</div>
)}
</div>
</div>
</li>
))}
</ul>
{props.document.status !== "COMPLETED" && (
<Button
icon={UserPlusIcon}
className="mt-3"
onClick={() => {
createOrUpdateRecipient({
id: "",
email: "",
name: "",
documentId: props.document.id,
}).then((res) => {
append(res);
});
}}>
Add Signer
</Button>
)}
</form>
</FormProvider>
</div>
</div>
<Dialog
title="Ready to send"
document={props.document}
formValues={formValues}
open={open}
setLoading={setLoading}
setOpen={setOpen}
icon={<EnvelopeIcon className="h-6 w-6 text-green-600" aria-hidden="true" />}
/>
</>
);
};
RecipientsPage.getLayout = function getLayout(page: ReactElement) {
return <Layout>{page}</Layout>;
};
export async function getServerSideProps(context: any) {
const user = await getUserFromToken(context.req, context.res);
if (!user)
return {
redirect: {
destination: "/login",
permanent: false,
},
};
const { id: documentId } = context.query;
const document: PrismaDocument = await getDocument(+documentId, context.req, context.res);
return {
props: {
document: JSON.parse(JSON.stringify({ ...document, document: "" })),
},
};
}
export default RecipientsPage;

View File

@ -1,120 +0,0 @@
import Head from "next/head";
import Link from "next/link";
import prisma from "@documenso/prisma";
import PDFSigner from "../../../components/editor/pdf-signer";
import { NextPageWithLayout } from "../../_app";
import { ClockIcon } from "@heroicons/react/24/outline";
import { ReadStatus } from "@prisma/client";
import { DocumentStatus, FieldType } from "@prisma/client";
const SignPage: NextPageWithLayout = (props: any) => {
return (
<>
<Head>
<title>Sign | Documenso</title>
</Head>
{!props.expired ? (
<PDFSigner document={props.document} recipient={props.recipient} fields={props.fields} />
) : (
<>
<div className="mx-auto w-fit px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
<ClockIcon className="text-neon mr-1 inline w-10"></ClockIcon>
<h1 className="text-neon inline align-middle text-base font-medium">Time flies.</h1>
<p className="mt-2 text-4xl font-bold tracking-tight">This signing link is expired.</p>
<p className="mt-2 text-base text-gray-500">
Please ask {props.document.User.name ? `${props.document.User.name}` : `the sender`}{" "}
to resend it.
</p>
<div className="mx-auto w-fit pt-20 text-xl"></div>
</div>
<div>
<div className="relative mx-96">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center"></div>
</div>
</div>
<p className="mt-4 text-center text-sm text-gray-600">
Want to send of your own?{" "}
<Link href="/signup?source=expired" className="text-neon hover:text-neon font-medium">
Create your own Account
</Link>
</p>
</>
)}
</>
);
};
export async function getServerSideProps(context: any) {
const recipientToken: string = context.query["token"];
await prisma.recipient.updateMany({
where: {
token: recipientToken,
},
data: {
readStatus: ReadStatus.OPENED,
},
});
const recipient = await prisma.recipient.findFirst({
where: {
token: recipientToken,
},
include: {
Document: { include: { User: true } },
},
});
if (!recipient) {
return {
redirect: {
permanent: false,
destination: "/404",
},
};
}
// Document is already signed
if (recipient.Document.status === DocumentStatus.COMPLETED) {
return {
redirect: {
permanent: false,
destination: `/documents/${recipient.Document.id}/signed?token=${recipientToken}`,
},
};
}
// Clean up potential unsigned free place fields from UI from previous page visits
await prisma.field.deleteMany({
where: {
type: { in: [FieldType.FREE_SIGNATURE] },
Signature: { is: null },
},
});
const unsignedFields = await prisma.field.findMany({
where: {
documentId: recipient.Document.id,
recipientId: recipient.id,
Signature: { is: null },
},
include: {
Recipient: true,
Signature: true,
},
});
return {
props: {
recipient: JSON.parse(JSON.stringify(recipient)),
document: JSON.parse(JSON.stringify({ ...recipient.Document, document: "" })),
fields: JSON.parse(JSON.stringify(unsignedFields)),
expired: recipient.expired ? new Date(recipient.expired) < new Date() : false,
},
};
}
export default SignPage;

View File

@ -1,96 +0,0 @@
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import prisma from "@documenso/prisma";
import { Button, IconButton } from "@documenso/ui";
import { NextPageWithLayout } from "../../_app";
import { ArrowDownTrayIcon, CheckBadgeIcon } from "@heroicons/react/24/outline";
import { truncate } from "@documenso/lib/helpers";
const Signed: NextPageWithLayout = (props: any) => {
const router = useRouter();
const allRecipientsSigned = props.document.Recipient?.every(
(r: any) => r.signingStatus === "SIGNED"
);
return (
<>
<Head>
<title>Sign | Documenso</title>
</Head>
<div className="mx-auto w-fit px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
<CheckBadgeIcon className="text-neon mr-1 inline w-10"></CheckBadgeIcon>
<h1 className="text-neon inline align-middle text-base font-medium">It's done!</h1>
<p className="mt-2 text-4xl font-bold tracking-tight">
You signed "{truncate(props.document.title)}"
</p>
<p className="mt-2 max-w-sm text-base text-gray-500" hidden={allRecipientsSigned}>
You will be notfied when all recipients have signed.
</p>
<p className="mt-2 max-w-sm text-base text-gray-500" hidden={!allRecipientsSigned}>
All recipients signed.
</p>
<div className="mx-auto w-fit pt-20 text-xl" hidden={!allRecipientsSigned}>
<Button
icon={ArrowDownTrayIcon}
color="secondary"
onClick={(event: any) => {
event.preventDefault();
event.stopPropagation();
router.push(
"/api/documents/" + props.document.id + "?token=" + props.recipient.token
);
}}>
Download "{props.document.title}"
</Button>
</div>
</div>
<div>
<div className="relative mx-96">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center"></div>
</div>
</div>
<p className="mt-4 text-center text-sm text-gray-600">
Want to send slick signing links like this one?{" "}
<Link href="https://documenso.com" className="text-neon hover:text-neon font-medium">
Hosted Documenso is here!
</Link>
</p>
</>
);
};
export async function getServerSideProps(context: any) {
const recipientToken: string = context.query["token"];
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
token: recipientToken,
},
include: {
Document: { include: { Recipient: true } },
},
});
const fields = await prisma.field.findMany({
where: {
documentId: recipient.Document.id,
},
include: {
Recipient: true,
},
});
return {
props: {
document: JSON.parse(JSON.stringify(recipient.Document)),
fields: JSON.parse(JSON.stringify(fields)),
recipient: JSON.parse(JSON.stringify(recipient)),
},
};
}
export default Signed;

View File

@ -1,18 +0,0 @@
import { NextPageContext } from "next";
import { getSession } from "next-auth/react";
function RedirectPage() {
return;
}
export async function getServerSideProps(context: NextPageContext) {
const session = await getSession(context);
if (!session?.user?.email) {
return { redirect: { permanent: false, destination: "/login" } };
}
return { redirect: { permanent: false, destination: "/dashboard" } };
}
export default RedirectPage;

View File

@ -1,34 +0,0 @@
import Head from "next/head";
import { getUserFromToken } from "@documenso/lib/server";
import Login from "../components/login";
export default function LoginPage(props: any) {
return (
<>
<Head>
<title>Login | Documenso</title>
</Head>
<Login allowSignup={props.ALLOW_SIGNUP}></Login>
</>
);
}
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";
return {
props: {
ALLOW_SIGNUP,
},
};
}

View File

@ -1,3 +0,0 @@
import SettingsPage from ".";
export default SettingsPage;

View File

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

View File

@ -1,14 +0,0 @@
import type { ReactElement } from "react";
import Layout from "../../components/layout";
import Settings from "../../components/settings";
import type { NextPageWithLayout } from "../_app";
const SettingsPage: NextPageWithLayout = () => {
return <Settings></Settings>;
};
SettingsPage.getLayout = function getLayout(page: ReactElement) {
return <Layout>{page}</Layout>;
};
export default SettingsPage;

View File

@ -1,3 +0,0 @@
import SettingsPage from ".";
export default SettingsPage;

View File

@ -1,3 +0,0 @@
import SettingsPage from ".";
export default SettingsPage;

View File

@ -1,42 +0,0 @@
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 }) {
return (
<>
<Head>
<title>Signup | Documenso</title>
</Head>
<Signup source={props.source}></Signup>
</>
);
}
export async function getServerSideProps(context: any) {
if (process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "true")
return {
redirect: {
destination: "/login",
permanent: false,
},
};
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: {
source: signupSource ? signupSource : "",
},
};
}