wip: refresh design
@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals"
|
||||
],
|
||||
"rules": {
|
||||
"react/no-unescaped-entities": "off"
|
||||
}
|
||||
}
|
||||
37
apps/web/.gitignore
vendored
@ -1,37 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
1
apps/web/README.md
Normal file
@ -0,0 +1 @@
|
||||
# @documenso/web
|
||||
@ -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 everything 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { IconButton } from "@documenso/ui";
|
||||
import Logo from "../logo";
|
||||
import { XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import Draggable from "react-draggable";
|
||||
|
||||
const stc = require("string-to-color");
|
||||
|
||||
type FieldPropsType = {
|
||||
field: {
|
||||
color: string;
|
||||
type: string;
|
||||
position: any;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
id: string;
|
||||
Recipient: { name: ""; email: "" };
|
||||
};
|
||||
onPositionChanged: any;
|
||||
onDelete: any;
|
||||
hidden: boolean;
|
||||
};
|
||||
|
||||
export default function EditableField(props: FieldPropsType) {
|
||||
const [field, setField]: any = useState(props.field);
|
||||
const [position, setPosition]: any = useState({
|
||||
x: props.field.positionX,
|
||||
y: props.field.positionY,
|
||||
});
|
||||
const nodeRef = React.createRef<HTMLDivElement>();
|
||||
const onControlledDrag = (e: any, position: any) => {
|
||||
const { x, y } = position;
|
||||
setPosition({ x, y });
|
||||
};
|
||||
|
||||
const onDragStop = (e: any, position: any) => {
|
||||
if (!position) return;
|
||||
const { x, y } = position;
|
||||
|
||||
props.onPositionChanged({ x, y }, props.field.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
nodeRef={nodeRef}
|
||||
bounds="parent"
|
||||
position={position}
|
||||
onDrag={onControlledDrag}
|
||||
onStop={onDragStop}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
cancel="strong"
|
||||
onMouseDown={(e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{/* width: 192 height 96 */}
|
||||
<div
|
||||
hidden={props.hidden}
|
||||
ref={nodeRef}
|
||||
className="absolute top-0 left-0 m-auto h-16 w-48 cursor-move select-none flex-row-reverse p-2 text-center text-lg font-bold opacity-80"
|
||||
style={{
|
||||
background: stc(props.field.Recipient.email),
|
||||
}}>
|
||||
<div className="m-auto flex-row-reverse overflow-hidden text-center text-lg font-bold">
|
||||
{field.type}
|
||||
{field.type === "SIGNATURE" ? (
|
||||
<div className="text-center text-xs">
|
||||
{`${props.field.Recipient?.name} <${props.field.Recipient?.email}>`}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<strong>
|
||||
<IconButton
|
||||
className="absolute top-0 right-0 -m-5"
|
||||
color="secondary"
|
||||
icon={XCircleIcon}
|
||||
onClick={(event: any) => {
|
||||
props.onDelete(props.field.id);
|
||||
}}></IconButton>
|
||||
</strong>
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { classNames } from "@documenso/lib";
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import { FieldType } from "@prisma/client";
|
||||
|
||||
const stc = require("string-to-color");
|
||||
|
||||
export default function FieldTypeSelector(props: any) {
|
||||
const fieldTypes = [
|
||||
{
|
||||
name: "Signature",
|
||||
id: FieldType.SIGNATURE,
|
||||
},
|
||||
{ name: "Date", id: FieldType.DATE },
|
||||
];
|
||||
|
||||
const [selectedFieldType, setSelectedFieldType] = useState(fieldTypes[0].id);
|
||||
|
||||
useEffect(() => {
|
||||
props.onChange(selectedFieldType);
|
||||
}, [selectedFieldType]);
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
value={selectedFieldType}
|
||||
onChange={(e: any) => {
|
||||
setSelectedFieldType(e);
|
||||
}}>
|
||||
<div className="space-y-4">
|
||||
{fieldTypes.map((fieldType) => (
|
||||
<RadioGroup.Option
|
||||
onMouseDown={(e: any) => {
|
||||
if (e.button === 0) setSelectedFieldType(fieldType.id);
|
||||
}}
|
||||
key={fieldType.id}
|
||||
value={fieldType.id}
|
||||
className={({ checked, active }) =>
|
||||
classNames(
|
||||
checked ? "border-neon border-2" : "border-transparent",
|
||||
"relative block cursor-pointer select-none rounded-lg border bg-white px-3 py-2 hover:bg-slate-100 focus:outline-none sm:flex sm:justify-between"
|
||||
)
|
||||
}>
|
||||
{({ active, checked }) => (
|
||||
<>
|
||||
<span className="flex items-center">
|
||||
<span className="flex flex-col text-sm">
|
||||
<RadioGroup.Label as="span" className="font-medium text-gray-900">
|
||||
<span
|
||||
className="mr-3 inline-block h-4 w-4 flex-shrink-0 rounded-full align-middle"
|
||||
style={{
|
||||
background: stc(props.selectedRecipient?.email),
|
||||
}}
|
||||
/>
|
||||
<span className="align-middle">
|
||||
{" "}
|
||||
{fieldTypes.filter((e) => e.id === fieldType.id)[0].name}
|
||||
</span>
|
||||
</RadioGroup.Label>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
@ -1,120 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { createField } from "@documenso/features/editor";
|
||||
import { createOrUpdateField, deleteField } from "@documenso/lib/api";
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
|
||||
import FieldTypeSelector from "./field-type-selector";
|
||||
import RecipientSelector from "./recipient-selector";
|
||||
import { InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
const stc = require("string-to-color");
|
||||
|
||||
const PDFViewer = dynamic(() => import("./pdf-viewer"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function PDFEditor(props: any) {
|
||||
const router = useRouter();
|
||||
const [fields, setFields] = useState<any[]>(props.document.Field);
|
||||
const [selectedRecipient, setSelectedRecipient]: any = useState();
|
||||
const [selectedFieldType, setSelectedFieldType] = useState();
|
||||
const noRecipients =
|
||||
props?.document.Recipient.length === 0 || props?.document.Recipient.every((e: any) => !e.email);
|
||||
|
||||
function onPositionChangedHandler(position: any, id: any) {
|
||||
if (!position) return;
|
||||
const movedField = fields.find((e) => e.id == id);
|
||||
movedField.positionX = position.x.toFixed(0);
|
||||
movedField.positionY = position.y.toFixed(0);
|
||||
createOrUpdateField(props.document, movedField);
|
||||
|
||||
// no instant redraw necessary, position information for saving or later rerender is enough
|
||||
// setFields(newFields);
|
||||
}
|
||||
|
||||
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);
|
||||
deleteField(field).catch((err) => {
|
||||
setFields(fieldWithoutRemoved.concat(removedField));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div hidden={!noRecipients} className="rounded-md bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p className="text-sm text-yellow-700">
|
||||
This document does not have any recipients. Add recipients to create fields.
|
||||
</p>
|
||||
<p className="mt-3 text-sm md:mt-0 md:ml-6">
|
||||
<Link
|
||||
href={NEXT_PUBLIC_WEBAPP_URL + "/documents/" + props.document.id + "/recipients"}
|
||||
className="whitespace-nowrap font-medium text-yellow-700 hover:text-yellow-600">
|
||||
Add Recipients
|
||||
<span aria-hidden="true"> →</span>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PDFViewer
|
||||
style={{
|
||||
cursor: !noRecipients
|
||||
? `url("https://place-hold.it/110x64/37f095/FFFFFF&text=${selectedFieldType}") 55 32, auto`
|
||||
: "",
|
||||
}}
|
||||
readonly={false}
|
||||
document={props.document}
|
||||
fields={fields}
|
||||
onPositionChanged={onPositionChangedHandler}
|
||||
onDelete={onDeleteHandler}
|
||||
pdfUrl={`${NEXT_PUBLIC_WEBAPP_URL}/api/documents/${router.query.id}`}
|
||||
onMouseUp={(e: any, page: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(e: any, page: number) => {
|
||||
if (e.button === 0) addField(e, page);
|
||||
}}></PDFViewer>
|
||||
<div
|
||||
hidden={noRecipients}
|
||||
className="fixed left-0 top-1/3 max-w-xs rounded-md border border-slate-300 bg-white py-4 pr-5">
|
||||
<RecipientSelector
|
||||
recipients={props?.document?.Recipient}
|
||||
onChange={setSelectedRecipient}
|
||||
/>
|
||||
<hr className="m-3 border-slate-300"></hr>
|
||||
<FieldTypeSelector
|
||||
selectedRecipient={selectedRecipient}
|
||||
onChange={setSelectedFieldType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function addField(e: any, page: number) {
|
||||
if (!selectedRecipient) return;
|
||||
if (!selectedFieldType) return;
|
||||
if (noRecipients) return;
|
||||
|
||||
const signatureField = createField(e, page, selectedRecipient, selectedFieldType);
|
||||
|
||||
createOrUpdateField(props?.document, signatureField).then((res) => {
|
||||
setFields((prevState) => [...prevState, res]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,186 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { createField } from "@documenso/features/editor";
|
||||
import { createOrUpdateField, deleteField, signDocument } from "@documenso/lib/api";
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
|
||||
import { Button } from "@documenso/ui";
|
||||
import Logo from "../logo";
|
||||
import SignatureDialog from "./signature-dialog";
|
||||
import { CheckBadgeIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { FieldType } from "@prisma/client";
|
||||
|
||||
const PDFViewer = dynamic(() => import("./pdf-viewer"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function PDFSigner(props: any) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [signingDone, setSigningDone] = useState(false);
|
||||
const [localSignatures, setLocalSignatures] = useState<any[]>([]);
|
||||
const [fields, setFields] = useState<any[]>(props.fields);
|
||||
const signatureFields = fields.filter((field) => field.type === FieldType.SIGNATURE);
|
||||
const [dialogField, setDialogField] = useState<any>();
|
||||
|
||||
useEffect(() => {
|
||||
setSigningDone(checkIfSigningIsDone());
|
||||
}, [fields]);
|
||||
|
||||
function onClick(item: any) {
|
||||
if (item.type === "SIGNATURE") {
|
||||
setDialogField(item);
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
function onDialogClose(dialogResult: any) {
|
||||
// todo handle signature removed from field, remove free field if dialogresult is empty (or the id )
|
||||
if (!dialogResult && dialogField.type === "FREE_SIGNATURE") {
|
||||
onDeleteHandler(dialogField.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dialogResult) return;
|
||||
|
||||
const signature = {
|
||||
fieldId: dialogField.id,
|
||||
type: dialogResult.type,
|
||||
typedSignature: dialogResult.typedSignature,
|
||||
signatureImage: dialogResult.signatureImage,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SignatureDialog open={open} setOpen={setOpen} onClose={onDialogClose} />
|
||||
<div className="bg-neon p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex gap-x-2 items-center">
|
||||
<Logo className="h-8 w-8 text-black" />
|
||||
<h2 className="text-2xl font-semibold">Documenso</h2>
|
||||
</div>
|
||||
|
||||
<div className="mx-3 flex-1 items-center justify-start text-center md:flex md:justify-between">
|
||||
<p className="text-lg text-slate-700">
|
||||
{props.document.User.name
|
||||
? `${props.document.User.name} (${props.document.User.email})`
|
||||
: 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">
|
||||
<Button
|
||||
disabled={!signingDone}
|
||||
color="secondary"
|
||||
icon={CheckBadgeIcon}
|
||||
onClick={() => {
|
||||
signDocument(props.document, localSignatures, `${router.query.token}`).then(
|
||||
() => {
|
||||
router.push(
|
||||
`/documents/${props.document.id}/signed?token=${router.query.token}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}}>
|
||||
Done
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{signatureFields.length === 0 ? (
|
||||
<div className="bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p className="text-sm text-yellow-700">
|
||||
You can sign this document anywhere you like, but maybe look for a signature line.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<PDFViewer
|
||||
style={{
|
||||
cursor:
|
||||
signatureFields.length === 0
|
||||
? `url("https://place-hold.it/110x64/37f095/ffffff&text=Signature") 55 32, auto`
|
||||
: "",
|
||||
}}
|
||||
readonly={true}
|
||||
document={props.document}
|
||||
fields={fields}
|
||||
pdfUrl={`${NEXT_PUBLIC_WEBAPP_URL}/api/documents/${router.query.id}?token=${router.query.token}`}
|
||||
onClick={onClick}
|
||||
onMouseDown={function onMouseDown(e: any, page: number) {
|
||||
if (signatureFields.length === 0) addFreeSignature(e, page, props.recipient);
|
||||
}}
|
||||
onMouseUp={() => {}}
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,168 +0,0 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import EditableField from "./editable-field";
|
||||
import SignableField from "./signable-field";
|
||||
import { FieldType } from "@prisma/client";
|
||||
import { Document, Page } from "react-pdf/dist/esm/entry.webpack5";
|
||||
import short from "short-uuid";
|
||||
|
||||
export default function PDFViewer(props) {
|
||||
const [numPages, setNumPages] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pageHeight, setPageHeight] = useState(0);
|
||||
|
||||
function onPositionChangedHandler(position, id) {
|
||||
props.onPositionChanged(position, id);
|
||||
}
|
||||
|
||||
function onDeleteHandler(id) {
|
||||
props.onDelete(id);
|
||||
}
|
||||
|
||||
function onDocumentLoadSuccess({ numPages: nextNumPages }) {
|
||||
setNumPages(nextNumPages);
|
||||
}
|
||||
|
||||
const options = {
|
||||
cMapUrl: "cmaps/",
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: "standard_fonts/",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
hidden={loading}
|
||||
onMouseUp={props.onMouseUp}
|
||||
style={{ height: numPages * pageHeight + 1000 }}>
|
||||
<div className="mt-6 max-w-xs"></div>
|
||||
<Document
|
||||
file={props.pdfUrl}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
options={options}
|
||||
renderMode="canvas"
|
||||
className="absolute left-0 right-0 mx-auto w-auto">
|
||||
{Array.from({ length: numPages }, (_, index) => (
|
||||
<Fragment key={short.generate().toString()}>
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 0) props.onMouseDown(e, index);
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
if (e.button === 0) props.onMouseUp(e, index);
|
||||
}}
|
||||
key={short.generate().toString()}
|
||||
style={{
|
||||
position: "relative",
|
||||
...props.style,
|
||||
}}
|
||||
className="mx-auto w-fit">
|
||||
<Page
|
||||
className="mt-5"
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
onLoadSuccess={(e) => {
|
||||
if (e.height) setPageHeight(e.height);
|
||||
setLoading(false);
|
||||
}}
|
||||
onRenderError={() => setLoading(false)}></Page>
|
||||
{props?.fields
|
||||
.filter((field) => field.page === index)
|
||||
.map((field) =>
|
||||
props.readonly ? (
|
||||
<SignableField
|
||||
onClick={props.onClick}
|
||||
key={field.id}
|
||||
field={field}
|
||||
className="absolute"
|
||||
onDelete={onDeleteHandler}></SignableField>
|
||||
) : (
|
||||
<EditableField
|
||||
hidden={
|
||||
field.Signature ||
|
||||
field.inserted ||
|
||||
field.type === FieldType.FREE_SIGNATURE
|
||||
}
|
||||
key={field.id}
|
||||
field={field}
|
||||
className="absolute"
|
||||
onPositionChanged={onPositionChangedHandler}
|
||||
onDelete={onDeleteHandler}></EditableField>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</Document>
|
||||
</div>
|
||||
<div className="mx-auto mt-10 w-[600px]" 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 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 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 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 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 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { classNames } from "@documenso/lib";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
const stc = require("string-to-color");
|
||||
|
||||
export default function RecipientSelector(props: any) {
|
||||
const [selectedRecipient, setSelectedRecipient]: any = useState(props?.recipients[0]);
|
||||
|
||||
useEffect(() => {
|
||||
props.onChange(selectedRecipient);
|
||||
}, [selectedRecipient]);
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
value={selectedRecipient}
|
||||
onChange={(e: any) => {
|
||||
setSelectedRecipient(e);
|
||||
}}>
|
||||
{({ open }) => (
|
||||
<div className="relative mt-1 mb-2">
|
||||
<Listbox.Button className="focus:border-neon focus:ring-neon relative w-full cursor-default select-none rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-1 sm:text-sm">
|
||||
<span className="flex items-center">
|
||||
<span
|
||||
className="inline-block h-4 w-4 flex-shrink-0 rounded-full"
|
||||
style={{ background: stc(selectedRecipient?.email) }}
|
||||
/>
|
||||
<span className="ml-3 block truncate">
|
||||
{`${selectedRecipient?.name} <${selectedRecipient?.email}>`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{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"
|
||||
)
|
||||
}
|
||||
value={recipient}>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className="inline-block h-4 w-4 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
background: stc(recipient?.email),
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
selected ? "font-semibold" : "font-normal",
|
||||
"ml-3 block truncate"
|
||||
)}>
|
||||
{`${recipient?.name} <${recipient?.email || 'unknown'}>`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={classNames(
|
||||
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" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { classNames } from "@documenso/lib";
|
||||
import { IconButton } from "@documenso/ui";
|
||||
import { XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import Draggable from "react-draggable";
|
||||
|
||||
const stc = require("string-to-color");
|
||||
|
||||
type FieldPropsType = {
|
||||
field: {
|
||||
color: string;
|
||||
type: string;
|
||||
position: any;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
id: string;
|
||||
Recipient: { name: ""; email: "" };
|
||||
};
|
||||
onClick: any;
|
||||
onDelete: any;
|
||||
};
|
||||
|
||||
export default function SignableField(props: FieldPropsType) {
|
||||
const [field, setField]: any = useState(props.field);
|
||||
const [position, setPosition]: any = useState({
|
||||
x: props.field.positionX,
|
||||
y: props.field.positionY,
|
||||
});
|
||||
const nodeRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
nodeRef={nodeRef}
|
||||
bounds="parent"
|
||||
position={position}
|
||||
defaultPosition={{ x: 0, y: 0 }}
|
||||
cancel="div"
|
||||
onMouseDown={(e: any) => {
|
||||
// e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<div
|
||||
onClick={(e: any) => {
|
||||
if (!field?.signature) props.onClick(props.field);
|
||||
}}
|
||||
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",
|
||||
field.type === "SIGNATURE" ? "cursor-pointer hover:brightness-50" : "cursor-not-allowed"
|
||||
)}
|
||||
style={{
|
||||
background: stc(props.field.Recipient.email),
|
||||
}}>
|
||||
<div hidden={field?.signature} className="my-4 font-medium">
|
||||
{field.type === "SIGNATURE" ? "SIGN HERE" : ""}
|
||||
{field.type === "DATE" ? <small>Date (filled on sign)</small> : ""}
|
||||
</div>
|
||||
<div
|
||||
hidden={!field?.signature}
|
||||
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>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
{field?.signature?.type === "draw" ? (
|
||||
<img className="h-16 w-48" src={field?.signature?.signatureImage} />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<IconButton
|
||||
icon={XCircleIcon}
|
||||
color="secondary"
|
||||
className="absolute top-0 right-0 -m-5"
|
||||
onClick={(event: any) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const newField = { ...field };
|
||||
newField.signature = null;
|
||||
setField(newField);
|
||||
// remove not only signature but whole field if it is a freely places signature
|
||||
if (field.type === "FREE_SIGNATURE") props.onDelete(field.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
@ -1,207 +0,0 @@
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { classNames } from "@documenso/lib";
|
||||
import { localStorage } from "@documenso/lib";
|
||||
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 },
|
||||
{ name: "Draw", icon: PencilIcon, current: false },
|
||||
];
|
||||
|
||||
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 occurring.
|
||||
const showCanvas = useDebouncedValue<boolean>(props.open, 1);
|
||||
let signCanvasRef: any | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setTypedSignature(localStorage.getItem("typedSignature") || "");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root show={props.open} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
onClose={() => {
|
||||
props.setOpen(false);
|
||||
setCurrent(tabs[0]);
|
||||
}}>
|
||||
<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 className="">
|
||||
<div className="mb-3 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<a
|
||||
key={tab.name}
|
||||
onClick={() => {
|
||||
setCurrent(tab);
|
||||
}}
|
||||
className={classNames(
|
||||
tab.current
|
||||
? "border-neon text-neon"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"group inline-flex cursor-pointer items-center border-b-2 py-4 px-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={tab.current ? "page" : undefined}>
|
||||
<tab.icon
|
||||
className={classNames(
|
||||
tab.current
|
||||
? "text-neon"
|
||||
: "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-0.5 mr-2 h-5 w-5"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{tab.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
{isCurrentTab("Type") ? (
|
||||
<div>
|
||||
<div className="my-7 mb-3 border-b border-gray-300">
|
||||
<input
|
||||
value={typedSignature}
|
||||
onChange={(e) => {
|
||||
setTypedSignature(e.target.value);
|
||||
}}
|
||||
className={classNames(
|
||||
typedSignature ? "font-qwigley text-4xl" : "",
|
||||
"focus:border-neon focus:ring-neon mt-14 block h-10 w-full text-center align-bottom text-2xl leading-none"
|
||||
)}
|
||||
placeholder="Kindly type your name"
|
||||
/>
|
||||
</div>
|
||||
<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"
|
||||
disabled={!typedSignature}
|
||||
onClick={() => {
|
||||
localStorage.setItem("typedSignature", typedSignature);
|
||||
props.onClose({
|
||||
type: "type",
|
||||
typedSignature: typedSignature,
|
||||
});
|
||||
}}>
|
||||
Sign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{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}
|
||||
onClick={() => {
|
||||
signCanvasRef?.clear();
|
||||
setSignatureEmpty(signCanvasRef?.isEmpty());
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
|
||||
function isCurrentTab(tabName: string): boolean {
|
||||
return currentTab.name === tabName;
|
||||
}
|
||||
|
||||
function setCurrent(t: any) {
|
||||
tabs.forEach((tab) => {
|
||||
tab.current = tab.name === t.name;
|
||||
});
|
||||
setCurrentTab(t);
|
||||
}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
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 { BillingWarning } from "./billing-warning";
|
||||
import Navigation from "./navigation";
|
||||
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
|
||||
import { SubscriptionStatus } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
function useRedirectToLoginIfUnauthenticated() {
|
||||
const { data: session, status } = useSession();
|
||||
const loading = status === "loading";
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !session) {
|
||||
router.replace({
|
||||
pathname: "/login",
|
||||
query: {
|
||||
callbackUrl: `${NEXT_PUBLIC_WEBAPP_URL}/${location.pathname}${location.search}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, session]);
|
||||
|
||||
return {
|
||||
loading: loading && !session,
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Layout({ children }: any) {
|
||||
useRedirectToLoginIfUnauthenticated();
|
||||
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-full">
|
||||
<Navigation />
|
||||
|
||||
<main>
|
||||
<BillingWarning />
|
||||
|
||||
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
|
||||
import { Button } from "@documenso/ui";
|
||||
import Logo from "./logo";
|
||||
import { LockClosedIcon } from "@heroicons/react/20/solid";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
interface LoginValues {
|
||||
email: string;
|
||||
password: string;
|
||||
totpCode: string;
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
export default function Login(props: any) {
|
||||
const router = useRouter();
|
||||
const methods = useForm<LoginValues>();
|
||||
const { register, formState } = methods;
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
let callbackUrl = typeof router.query?.callbackUrl === "string" ? router.query.callbackUrl : "";
|
||||
|
||||
// If not absolute URL, make it absolute
|
||||
if (!/^https?:\/\//.test(callbackUrl)) {
|
||||
callbackUrl = `${NEXT_PUBLIC_WEBAPP_URL}/${callbackUrl}`;
|
||||
}
|
||||
|
||||
const onSubmit = async (values: LoginValues) => {
|
||||
setErrorMessage(null);
|
||||
const res = await toast.promise(
|
||||
signIn<"credentials">("credentials", {
|
||||
...values,
|
||||
callbackUrl,
|
||||
redirect: false,
|
||||
}),
|
||||
{
|
||||
loading: "Logging in...",
|
||||
success: "Login successful.",
|
||||
error: "Could not log in :/",
|
||||
},
|
||||
{
|
||||
style: {
|
||||
minWidth: "200px",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!res) {
|
||||
setErrorMessage("Error");
|
||||
toast.dismiss();
|
||||
toast.error("Something went wrong.");
|
||||
} else if (!res.error) {
|
||||
// we're logged in, let's do a hard refresh to the original url
|
||||
router.push(callbackUrl);
|
||||
} else {
|
||||
toast.dismiss();
|
||||
if (res.status == 401) {
|
||||
toast.error("Invalid email or password.");
|
||||
} else {
|
||||
toast.error("Could not login.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
<Logo className="mx-auto h-20 w-auto text-black"></Logo>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
</div>
|
||||
<FormProvider {...methods}>
|
||||
<form className="mt-8 space-y-6" onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<input type="hidden" name="remember" defaultValue="true" />
|
||||
<div className="-space-y-px rounded-md shadow-sm">
|
||||
<div>
|
||||
<label htmlFor="email-address" className="sr-only">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
{...register("email")}
|
||||
id="email-address"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
|
||||
placeholder="Email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
{...register("password")}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-b-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="hover:text-neon-700 font-medium text-gray-500">
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting}
|
||||
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 duration-200 disabled:disabled:bg-gray-600 disabled:group-hover:bg-gray-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{props.allowSignup ? (
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Are you new here?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="hover:text-neon-700 font-medium text-gray-500 duration-200">
|
||||
Create a new Account
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Like Documenso{" "}
|
||||
<Link
|
||||
href="https://documenso.com"
|
||||
className="text-neon hover:text-neon font-medium">
|
||||
Hosted Documenso is here!
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
{/* <Toaster position="top-center" /> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { classNames } from "@documenso/lib";
|
||||
|
||||
export default function Logo(props: any) {
|
||||
return (
|
||||
<>
|
||||
<svg viewBox="0 0 64 64" {...props}>
|
||||
<rect width="88.6758041381836" height="32.18000030517578" fill="transparent"></rect>
|
||||
<path
|
||||
d="M27.07 9.25832C26.333 9.92796 25.5176 10.7145 24.5857 11.6341C23.9957 12.0973 23.2682 12.3587 22.5117 12.3733L19.3896 12.4333L20.2992 11.5237C25.815 6.0079 28.5729 3.25 32 3.25C35.4271 3.25 38.185 6.00789 43.7008 11.5237L44.6087 12.4317L41.5937 12.3749C40.7437 12.3588 39.9292 12.0311 39.3051 11.4539L37.4972 9.78198C37.3255 9.6212 37.1581 9.46631 36.9946 9.31712L36.897 9.22687L36.8953 9.22687C36.2778 8.667 35.7153 8.18958 35.1851 7.78508C33.6538 6.6167 32.7624 6.35263 32 6.35263C31.2376 6.35263 30.3462 6.6167 28.8149 7.78508C28.2783 8.19451 27.7085 8.67864 27.0821 9.24737L27.0814 9.24737L27.07 9.25832Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M54.6826 27.0051C54.5337 26.8419 54.3791 26.6748 54.2187 26.5035L52.5459 24.6946C51.9691 24.0709 51.6413 23.2571 51.6249 22.4077L51.5667 19.3896L52.4763 20.2992C57.9921 25.815 60.75 28.5729 60.75 32C60.75 35.4271 57.9921 38.185 52.4763 43.7008L51.5667 44.6104L51.6249 41.5923C51.6413 40.7429 51.9691 39.9291 52.5459 39.3054L54.2185 37.4968C54.379 37.3253 54.5337 37.1581 54.6827 36.9948L54.7731 36.897V36.8953C55.333 36.2778 55.8104 35.7153 56.2149 35.1851C57.3833 33.6538 57.6474 32.7624 57.6474 32C57.6474 31.2376 57.3833 30.3462 56.2149 28.8149C55.8104 28.2847 55.333 27.7222 54.7731 27.1047V27.103L54.6826 27.0051Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M36.9601 54.7143C37.1446 54.5464 37.334 54.3711 37.5289 54.1883L39.3054 52.5457C39.9294 51.9687 40.7435 51.6411 41.5932 51.6249L44.6096 51.5675L43.7008 52.4763C38.185 57.9921 35.4271 60.75 32 60.75C28.5729 60.75 25.815 57.9921 20.2992 52.4763L19.3896 51.5667L22.4141 51.6248C23.2599 51.641 24.0705 51.9659 24.6934 52.5383L25.9131 53.6592C27.0267 54.726 27.9626 55.5647 28.8149 56.2149C30.3462 57.3833 31.2376 57.6474 32 57.6474C32.7624 57.6474 33.6538 57.3833 35.1851 56.2149C35.7217 55.8055 36.2915 55.3214 36.9179 54.7526H36.9187L36.9601 54.7143Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.26202 36.9341C9.44675 37.1373 9.64036 37.3465 9.8432 37.5625L11.4547 39.3051C12.0317 39.929 12.3594 40.7431 12.3756 41.5927L12.4333 44.6104L11.5237 43.7008C6.0079 38.185 3.25 35.4271 3.25 32C3.25 28.5729 6.00789 25.815 11.5237 20.2992L12.4325 19.3904L12.3754 22.4067C12.3593 23.2567 12.0314 24.0711 11.4541 24.6952L9.79271 26.4913C9.62762 26.6675 9.46871 26.8392 9.3158 27.0069L9.22687 27.103L9.22687 27.1047C8.66699 27.7222 8.18958 28.2847 7.78508 28.8149C6.6167 30.3462 6.35263 31.2376 6.35263 32C6.35263 32.7624 6.6167 33.6538 7.78508 35.1851C8.1946 35.7219 8.67887 36.2918 9.24777 36.9184L9.24777 36.9187L9.26202 36.9341Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.24777 27.0804L11.4541 24.6952C11.9658 24.1421 12.2815 23.4395 12.3579 22.6951C12.367 21.4688 12.387 20.3991 12.4313 19.4536L12.4337 19.3242L12.4377 19.3202C12.4785 18.5034 12.5382 17.7805 12.6257 17.1297C12.8823 15.2207 13.3259 14.4037 13.865 13.8646C14.4041 13.3255 15.2211 12.882 17.1301 12.6253C17.7929 12.5362 18.5306 12.4759 19.3661 12.4351L19.3675 12.4337L19.4131 12.4329C20.3923 12.3861 21.5054 12.3657 22.7886 12.3569C23.5626 12.2798 24.2914 11.9441 24.8545 11.3998L27.0813 9.24742H25.7951C17.9946 9.24742 14.0944 9.24742 11.6711 11.6707C9.24777 14.094 9.24777 17.9943 9.24777 25.7948V27.0804Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.24777 36.9187V38.2053C9.24777 46.0058 9.24777 49.9061 11.6711 52.3294C14.0944 54.7527 17.9946 54.7527 25.7951 54.7527H38.2057C46.0062 54.7527 49.9064 54.7527 52.3297 52.3294C54.753 49.9061 54.753 46.0058 54.753 38.2053V36.9187L52.5459 39.3054C52.0356 39.8571 51.7203 40.5577 51.643 41.3C51.6337 42.5529 51.613 43.6424 51.5668 44.603L51.5663 44.6325L51.5654 44.6334C51.5246 45.4693 51.4643 46.2073 51.3752 46.8704C51.1185 48.7794 50.6749 49.5964 50.1358 50.1355C49.5967 50.6746 48.7797 51.1181 46.8707 51.3748C46.2197 51.4623 45.4965 51.522 44.6793 51.5628L44.6758 51.5663L44.5626 51.5684C43.6127 51.6132 42.5373 51.6334 41.3032 51.6426C40.5597 51.7193 39.858 52.0347 39.3054 52.5457L36.9187 54.7526L27.103 54.7526L24.6934 52.5383C24.1424 52.032 23.4445 51.7193 22.7052 51.6426C21.4558 51.6334 20.3688 51.6129 19.4101 51.5671L19.3675 51.5663L19.3662 51.565C18.5307 51.5242 17.7929 51.4639 17.1301 51.3748C15.2211 51.1181 14.4041 50.6746 13.865 50.1355C13.3259 49.5964 12.8823 48.7794 12.6257 46.8704C12.5365 46.2075 12.4763 45.4698 12.4355 44.6342L12.4337 44.6325L12.4326 44.5753C12.3874 43.6221 12.367 42.5422 12.3579 41.3022C12.281 40.559 11.9655 39.8575 11.4547 39.3051L9.24777 36.9187Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M51.643 22.7C51.7203 23.4423 52.0356 24.1428 52.5459 24.6946L54.753 27.0813V25.7948C54.753 17.9943 54.753 14.094 52.3297 11.6707C49.9064 9.24742 46.0062 9.24742 38.2057 9.24742H36.9192L39.3051 11.4539C39.8586 11.9658 40.5618 12.2815 41.3067 12.3575C42.5257 12.3666 43.5898 12.3865 44.531 12.4302L44.7192 12.4337L44.725 12.4396C45.5235 12.4803 46.2319 12.5394 46.8707 12.6253C48.7797 12.882 49.5967 13.3255 50.1358 13.8646C50.6749 14.4037 51.1185 15.2207 51.3752 17.1297C51.4643 17.7928 51.5246 18.5307 51.5654 19.3666L51.5663 19.3675L51.5668 19.3971C51.613 20.3577 51.6337 21.4471 51.643 22.7Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M29.6453 18.2543L27.5526 20.0304C27.1792 20.3474 26.7112 20.5317 26.2219 20.5545L22.7195 20.7177L24.5458 18.8913C28.071 15.3661 29.8336 13.6035 32.0239 13.6035C34.2142 13.6035 35.9768 15.3661 39.502 18.8913L41.3172 20.7065L37.7657 20.5526C37.2678 20.531 36.7917 20.3422 36.4143 20.0167L34.8345 18.6538C34.2799 18.1319 33.8096 17.7194 33.3805 17.392C32.5014 16.7212 32.168 16.706 32.0239 16.7059C31.8799 16.7059 31.5465 16.7212 30.6674 17.392C30.6533 17.4027 30.6393 17.4135 30.6252 17.4243L30.6232 17.4243L30.6024 17.4419C30.3079 17.6703 29.9934 17.9385 29.6453 18.2543Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M46.4935 30.4715C45.8957 29.7157 45.0376 28.8234 43.7954 27.5741C43.5923 27.2496 43.4753 26.8756 43.4596 26.4879L43.306 22.6953L45.1106 24.4999C48.6358 28.0251 50.3985 29.7878 50.3985 31.978C50.3985 34.1683 48.6358 35.9309 45.1106 39.4561L43.2963 41.2705L43.4711 37.6457C43.4954 37.142 43.6908 36.6617 44.025 36.284L45.352 34.7845C45.7709 34.3392 46.1192 33.9484 46.4095 33.5895L46.573 33.4048V33.3829C46.5854 33.3667 46.5978 33.3506 46.61 33.3346C47.2808 32.4555 47.296 32.1221 47.296 31.978C47.296 31.834 47.2808 31.5006 46.61 30.6215C46.5978 30.6054 46.5854 30.5894 46.573 30.5732V30.555L46.4935 30.4715Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.4826 30.5696L19.902 27.9386C20.2447 27.566 20.4494 27.0873 20.4821 26.5821L20.616 24.5168C20.6363 23.8629 20.6699 23.3105 20.7254 22.829L20.7349 22.6825L20.7445 22.673C20.7466 22.6564 20.7488 22.6398 20.751 22.6234C20.8983 21.5275 21.1233 21.2809 21.2251 21.1791C21.327 21.0772 21.5736 20.8523 22.6695 20.7049C23.0213 20.6576 23.4118 20.6238 23.8544 20.5996L26.8259 20.3064C27.3027 20.2594 27.7513 20.0591 28.1046 19.7357L30.6159 17.4365H28.0583C23.0729 17.4365 20.5802 17.4365 19.0314 18.9853C17.712 20.3048 17.5166 22.3093 17.4877 25.9566C17.4826 26.5905 17.4826 27.274 17.4826 28.0122L17.4826 30.5632V30.5696Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.4826 33.3865V33.3957L17.4826 35.9439C17.4826 36.6821 17.4826 37.3656 17.4877 37.9995C17.5166 41.6468 17.7119 43.6514 19.0314 44.9708C20.3509 46.2903 22.3554 46.4856 26.0028 46.5146C26.6366 46.5196 27.3201 46.5196 28.0583 46.5196H30.6059H33.4384H35.99C36.728 46.5196 37.4114 46.5196 38.0451 46.5146C41.6927 46.4856 43.6974 46.2903 45.0169 44.9708C46.5657 43.422 46.5657 40.9293 46.5657 35.9439V33.3787L43.9873 36.1746C43.7295 36.4542 43.5498 36.7933 43.462 37.158C43.457 38.9702 43.4324 40.2302 43.3142 41.2003L43.313 41.252L43.3071 41.2579C43.3039 41.283 43.3006 41.308 43.2973 41.3327C43.15 42.4286 42.925 42.6752 42.8231 42.7771C42.7213 42.8789 42.4747 43.1039 41.3788 43.2512C41.3541 43.2545 41.3291 43.2578 41.304 43.261L41.2979 43.2671L41.2111 43.2724C40.6594 43.3378 40.0141 43.3737 39.2282 43.3934L37.4602 43.5013C36.9569 43.5321 36.4791 43.7335 36.1058 44.0725L33.4107 46.5196H30.5938L27.8997 44.0008C27.5451 43.6693 27.0925 43.4643 26.612 43.4152C26.4039 43.4144 26.2033 43.4132 26.0098 43.4117C24.5576 43.3999 23.5048 43.3635 22.6695 43.2512C21.5736 43.1039 21.327 42.8789 21.2251 42.7771C21.1233 42.6752 20.8983 42.4286 20.751 41.3327C20.7488 41.3163 20.7466 41.2998 20.7445 41.2832L20.7349 41.2737L20.734 41.2529L20.7304 41.1702C20.6321 40.3446 20.6002 39.3097 20.5899 37.9068L20.5744 37.5474C20.5703 37.451 20.5598 37.3554 20.5434 37.2613C20.4714 36.8491 20.2836 36.4635 19.9993 36.1511L17.4826 33.3865Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M43.4361 24.6464L43.5814 26.617C43.6181 27.1141 43.8212 27.5842 44.158 27.9516L46.5657 30.5773V28.0122C46.5657 23.0268 46.5657 20.5341 45.0169 18.9853C43.4681 17.4365 40.9754 17.4365 35.99 17.4365H33.4104L35.9941 19.7902C36.3562 20.1201 36.8173 20.3206 37.3055 20.3606L40.3018 20.6057C40.5881 20.6227 40.8522 20.6441 41.098 20.6709L41.3195 20.689L41.3288 20.6983C41.3455 20.7005 41.3622 20.7027 41.3788 20.7049C42.4747 20.8523 42.7213 21.0772 42.8231 21.1791C42.925 21.2809 43.15 21.5275 43.2973 22.6234C43.3724 23.1822 43.4136 23.8384 43.4361 24.6464Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20.7437 41.2626L20.734 41.2529L20.7349 41.2737L20.7445 41.2832L20.7437 41.2626Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M22.7418 43.2607L26.0098 43.4117C24.5992 43.4003 23.5654 43.3656 22.7418 43.2607Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M20.6049 37.695C20.5987 37.5479 20.578 37.4027 20.5434 37.2613C20.5598 37.3554 20.5703 37.451 20.5744 37.5474L20.5899 37.9068C20.6002 39.3097 20.6321 40.3446 20.7304 41.1702L20.734 41.2529L20.7437 41.2626L20.6049 37.695Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.6494 31.978C13.6494 33.8441 14.9288 35.3997 17.4877 37.9995C17.4826 37.3656 17.4826 36.6821 17.4826 35.9439L17.4826 33.3957L17.4772 33.3895L17.4772 33.3858C17.464 33.3687 17.4508 33.3516 17.4379 33.3346C16.7671 32.4555 16.7518 32.1221 16.7518 31.978C16.7518 31.834 16.7671 31.5006 17.4379 30.6215C17.4526 30.6021 17.4675 30.5827 17.4826 30.5632L17.4826 28.0122C17.4826 27.274 17.4826 26.5905 17.4877 25.9566C14.9288 28.5563 13.6494 30.112 13.6494 31.978Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M30.6674 46.5641C30.6549 46.5546 30.6425 46.5451 30.63 46.5354H30.6232L30.6059 46.5196H28.0583C27.3201 46.5196 26.6366 46.5196 26.0028 46.5146C28.6023 49.0732 30.1579 50.3526 32.0239 50.3526C33.8899 50.3526 35.4455 49.0732 38.0451 46.5146C37.4114 46.5196 36.728 46.5196 35.99 46.5196H33.4384C33.419 46.5346 33.3997 46.5494 33.3805 46.5641C32.5014 47.2348 32.168 47.2501 32.0239 47.2501C31.8799 47.2501 31.5465 47.2348 30.6674 46.5641Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,269 +0,0 @@
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { getUser } from "@documenso/lib/api";
|
||||
import Logo from "./logo";
|
||||
import { Disclosure, Menu, Transition } from "@headlessui/react";
|
||||
import {
|
||||
ArrowRightOnRectangleIcon,
|
||||
Bars3Icon,
|
||||
BellIcon,
|
||||
ChartBarIcon,
|
||||
DocumentTextIcon,
|
||||
UserCircleIcon,
|
||||
WrenchIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import avatarFromInitials from "avatar-from-initials";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: "Dashboard",
|
||||
href: "/dashboard",
|
||||
current: false,
|
||||
icon: ChartBarIcon,
|
||||
},
|
||||
{
|
||||
name: "Documents",
|
||||
href: "/documents",
|
||||
current: false,
|
||||
icon: DocumentTextIcon,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
href: "/settings/profile",
|
||||
current: true,
|
||||
icon: WrenchIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const userNavigation = [
|
||||
{
|
||||
name: "Your Profile",
|
||||
href: "/settings/profile",
|
||||
icon: UserCircleIcon,
|
||||
},
|
||||
{
|
||||
name: "Sign out",
|
||||
href: "",
|
||||
click: async (e: any) => {
|
||||
e.preventDefault();
|
||||
const res: any = await toast.promise(
|
||||
signOut({ callbackUrl: "/login" }),
|
||||
{
|
||||
loading: "Logging out...",
|
||||
success: "Your are logged out.",
|
||||
error: "Could not log out :/",
|
||||
},
|
||||
{
|
||||
style: {
|
||||
minWidth: "200px",
|
||||
},
|
||||
success: {
|
||||
duration: 10000,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
icon: ArrowRightOnRectangleIcon,
|
||||
},
|
||||
];
|
||||
|
||||
function classNames(...classes: any) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type UserType = {
|
||||
id?: number | undefined;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
};
|
||||
|
||||
export default function TopNavigation() {
|
||||
const router = useRouter();
|
||||
const session = useSession();
|
||||
const [user, setUser] = useState({
|
||||
email: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getUser().then((res) => {
|
||||
res.json().then((j: any) => {
|
||||
setUser(j);
|
||||
});
|
||||
});
|
||||
}, [session]);
|
||||
|
||||
navigation.forEach((element) => {
|
||||
element.current =
|
||||
router.route.endsWith("/" + element.href.split("/")[1]) ||
|
||||
router.route.includes(element.href.split("/")[1]);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Disclosure as="nav" className="border-b border-gray-200 bg-white">
|
||||
{({ open, close }) => (
|
||||
<>
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 justify-between">
|
||||
<div className="flex">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex flex-shrink-0 items-center gap-x-2 self-center overflow-hidden">
|
||||
<Logo className="h-8 w-8 text-black" />
|
||||
</Link>
|
||||
|
||||
<div className="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-8">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "border-neon text-brown"
|
||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700",
|
||||
"inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon
|
||||
className="-ml-1 mr-3 inline h-6 w-6 flex-shrink-0"
|
||||
aria-hidden="true"></item.icon>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
document?.getElementById("mb")?.click();
|
||||
}}
|
||||
className="hidden cursor-pointer px-3 hover:bg-gray-200 sm:ml-6 sm:flex sm:items-center">
|
||||
<span className="text-sm">
|
||||
<p className="font-bold">{user?.name || ""}</p>
|
||||
<p>{user?.email}</p>
|
||||
</span>
|
||||
<Menu as="div" className="relative ml-3">
|
||||
<div>
|
||||
<Menu.Button
|
||||
id="mb"
|
||||
className="flex max-w-xs items-center rounded-full bg-white text-sm">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<div
|
||||
key={user?.email}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: avatarFromInitials(user?.name || "" || "", 40),
|
||||
}}
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{userNavigation.map((item) => (
|
||||
<Menu.Item key={item.name}>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={item.click}
|
||||
className={classNames(
|
||||
active ? "bg-gray-100" : "",
|
||||
"block px-4 py-2 text-sm text-gray-700"
|
||||
)}>
|
||||
<item.icon
|
||||
className="-ml-1 mr-3 inline h-6 w-6 flex-shrink-0"
|
||||
aria-hidden="true"></item.icon>
|
||||
{item.name}
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="-mr-2 flex items-center sm:hidden">
|
||||
{/* Mobile menu button */}
|
||||
<Disclosure.Button className="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500">
|
||||
<span className="sr-only">Open main menu</span>
|
||||
{open ? (
|
||||
<XMarkIcon className="block h-6 w-6" aria-hidden="true" />
|
||||
) : (
|
||||
<Bars3Icon className="block h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Disclosure.Panel className="sm:hidden">
|
||||
<div className="space-y-1 pt-2 pb-3">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "border-teal-500 bg-teal-50 text-teal-700"
|
||||
: "border-transparent text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-800",
|
||||
"block border-l-4 py-2 pl-3 pr-4 text-base font-medium"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}
|
||||
onClick={() => {
|
||||
close();
|
||||
}}>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4 pb-3">
|
||||
<div className="flex items-center px-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
key={user?.email}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: avatarFromInitials(user?.name || "" || "", 40),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-base font-medium text-gray-800">{user?.name || ""}</div>
|
||||
<div className="text-sm font-medium text-gray-500">{user?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
{userNavigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
onClick={
|
||||
item.href.includes("/settings/profile")
|
||||
? () => {
|
||||
close();
|
||||
}
|
||||
: item.click
|
||||
}
|
||||
href={item.href}
|
||||
className="block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800">
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
{/* <Toaster position="top-center"></Toaster> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,297 +0,0 @@
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
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 { useSession } from "next-auth/react";
|
||||
|
||||
const subNavigation = [
|
||||
{
|
||||
name: "Profile",
|
||||
href: "/settings/profile",
|
||||
icon: UserCircleIcon,
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
name: "Password",
|
||||
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) => {
|
||||
setUser(j);
|
||||
});
|
||||
});
|
||||
}, [session]);
|
||||
|
||||
const router = useRouter();
|
||||
subNavigation.forEach((element) => {
|
||||
element.current = element.href == router.route;
|
||||
});
|
||||
|
||||
const [savingTimeout, setSavingTimeout] = useState<any>();
|
||||
const [password, setPassword] = useState("");
|
||||
function handleNameChange(e: ChangeEvent<HTMLInputElement>): void {
|
||||
let u = { ...user };
|
||||
u.name = e.target.value;
|
||||
setUser(u);
|
||||
clearTimeout(savingTimeout);
|
||||
const t = setTimeout(() => {
|
||||
updateUser(u);
|
||||
}, 1000);
|
||||
|
||||
setSavingTimeout(t);
|
||||
}
|
||||
|
||||
const handleKeyPress = (event: any) => {
|
||||
if (event.key === "Enter") {
|
||||
clearTimeout(savingTimeout);
|
||||
updateUser(user);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Head>
|
||||
<title>Settings | Documenso</title>
|
||||
</Head>
|
||||
<header className="py-10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-brown text-3xl font-bold leading-tight tracking-tight">Settings</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className="mx-auto max-w-screen-xl px-4 pb-6 sm:px-6 lg:px-8 lg:pb-16"
|
||||
hidden={!user.email}>
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||
<div className="divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
|
||||
<aside className="py-6 lg:col-span-3">
|
||||
<nav className="space-y-1">
|
||||
{subNavigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? "border-neon-dark bg-teal-50 text-teal-700 hover:bg-teal-50 hover:text-teal-700"
|
||||
: "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900",
|
||||
"group flex items-center border-l-4 px-3 py-2 text-sm font-medium"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
item.current
|
||||
? "text-teal-500 group-hover:text-teal-500"
|
||||
: "text-gray-400 group-hover:text-gray-500",
|
||||
"-ml-1 mr-3 h-6 w-6 flex-shrink-0"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<form
|
||||
className="min-h-[251px] divide-y divide-gray-200 lg:col-span-9"
|
||||
action="#"
|
||||
method="POST"
|
||||
hidden={subNavigation.filter((e) => e.current)[0]?.name !== subNavigation[0].name}>
|
||||
{/* Profile 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">Profile</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Let people know who they are dealing with builds trust.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="my-6 grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<label htmlFor="first-name" className="block text-sm font-medium text-gray-700">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="first-name"
|
||||
id="first-name"
|
||||
value={user?.name || ""}
|
||||
onChange={(e) => handleNameChange(e)}
|
||||
onKeyDown={handleKeyPress}
|
||||
autoComplete="given-name"
|
||||
className="focus:border-neon focus:ring-neon mt-1 block w-full rounded-md border border-gray-300 py-2 px-3 shadow-sm focus:outline-none sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<label htmlFor="first-name" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
disabled
|
||||
value={user?.email!}
|
||||
type="text"
|
||||
name="first-name"
|
||||
id="first-name"
|
||||
autoComplete="given-name"
|
||||
className="focus:border-neon focus:ring-neon mt-1 block w-full rounded-md border border-gray-300 py-2 px-3 shadow-sm focus:outline-none disabled:bg-neutral-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{/* Passwords 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">Update Password</h2>
|
||||
|
||||
<div className="my-6 grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<label
|
||||
htmlFor="first-name"
|
||||
className="block text-sm font-medium text-gray-700">
|
||||
New Password
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="focus:border-neon focus:ring-neon mt-1 block w-full rounded-md border border-gray-300 py-2 px-3 shadow-sm focus:outline-none sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={password.length < 6}
|
||||
onClick={() => updateUser({ ...user, password })}>
|
||||
Save
|
||||
</Button>
|
||||
</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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -1,200 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { signup } from "@documenso/lib/api";
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from "@documenso/lib/constants";
|
||||
import { Button } from "@documenso/ui";
|
||||
import { XCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
type FormValues = {
|
||||
email: string;
|
||||
password: string;
|
||||
apiError: string;
|
||||
};
|
||||
|
||||
export default function Signup(props: { source: string }) {
|
||||
const form = useForm<FormValues>({});
|
||||
const {
|
||||
register,
|
||||
trigger,
|
||||
formState: { errors, isSubmitting },
|
||||
} = form;
|
||||
|
||||
const handleErrors = async (resp: Response) => {
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const signUp: SubmitHandler<FormValues> = async (data) => {
|
||||
await toast
|
||||
.promise(
|
||||
signup(props.source, data)
|
||||
.then(handleErrors)
|
||||
.then(async () => {
|
||||
await signIn<"credentials">("credentials", {
|
||||
...data,
|
||||
callbackUrl: `${NEXT_PUBLIC_WEBAPP_URL}/dashboard`,
|
||||
});
|
||||
}),
|
||||
{
|
||||
loading: "Creating your account...",
|
||||
success: "Done!",
|
||||
error: (err) => err.message,
|
||||
},
|
||||
{
|
||||
style: {
|
||||
minWidth: "200px",
|
||||
},
|
||||
}
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.dismiss();
|
||||
form.setError("apiError", { message: err.message });
|
||||
});
|
||||
};
|
||||
|
||||
function renderApiError() {
|
||||
if (!errors.apiError) return;
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{errors.apiError && <div>{errors.apiError?.message}</div>}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFormValidation() {
|
||||
if (!errors.password && !errors.email) return;
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{errors.password && <div>{errors.password?.message}</div>}
|
||||
</h3>
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{errors.email && <div>{errors.email?.message}</div>}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-full items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
|
||||
Create a shiny, new <br></br>Documenso Account{" "}
|
||||
<svg
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="rgb(17 24 39 / var(--tw-text-opacity))"
|
||||
className="mb-1 inline h-8 w-8">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||
/>
|
||||
</svg>
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Create your account and start using<br></br>
|
||||
state-of-the-art document signing for free.
|
||||
</p>
|
||||
</div>
|
||||
{renderApiError()}
|
||||
{renderFormValidation()}
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(signUp)}
|
||||
onChange={() => {
|
||||
form.clearErrors();
|
||||
trigger();
|
||||
}}
|
||||
className="mt-8 space-y-6">
|
||||
<input type="hidden" name="remember" defaultValue="true" />
|
||||
<div className="-space-y-px rounded-md shadow-sm">
|
||||
<div>
|
||||
<label htmlFor="email-address" className="sr-only">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
{...register("email")}
|
||||
id="email-address"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-t-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
|
||||
placeholder="Email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
{...register("password", {
|
||||
minLength: {
|
||||
value: 7,
|
||||
message: "Your password has to be at least 7 characters long.",
|
||||
},
|
||||
})}
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="focus:border-neon focus:ring-neon relative block w-full appearance-none rounded-none rounded-b-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-500 focus:z-10 focus:outline-none sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
form.clearErrors();
|
||||
}}
|
||||
className="sgroup relative flex w-full">
|
||||
Create Account
|
||||
</Button>
|
||||
<div className="pt-2">
|
||||
<div className="relative">
|
||||
<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-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">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
6
apps/web/next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@ -1,18 +1,15 @@
|
||||
require("dotenv").config({ path: "../../.env" });
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require('path');
|
||||
|
||||
const { parsed: env } = require('dotenv').config({
|
||||
path: path.join(__dirname, '../../.env.local'),
|
||||
});
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: false,
|
||||
transpilePackages: [
|
||||
"@documenso/prisma",
|
||||
"@documenso/lib",
|
||||
"@documenso/ui",
|
||||
"@documenso/pdf",
|
||||
"@documenso/features",
|
||||
"@documenso/signing",
|
||||
"react-signature-canvas",
|
||||
],
|
||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
||||
env,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
module.exports = config;
|
||||
|
||||
10729
apps/web/package-lock.json
generated
@ -2,64 +2,42 @@
|
||||
"name": "@documenso/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "PORT=3000 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p $PORT",
|
||||
"lint": "next lint",
|
||||
"db-studio": "prisma db studio",
|
||||
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook"
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/pdf": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@headlessui/react": "^1.7.4",
|
||||
"@heroicons/react": "^2.0.13",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"avatar-from-initials": "^1.0.3",
|
||||
"base64-arraybuffer": "^1.0.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"formidable": "^3.2.5",
|
||||
"next": "13.2.4",
|
||||
"next-auth": "^4.22.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-signpdf": "^1.5.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"nodemailer-sendgrid": "^1.0.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"placeholder-loading": "^0.6.0",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.214.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-plausible": "^3.7.2",
|
||||
"perfect-freehand": "^1.2.0",
|
||||
"react": "18.2.0",
|
||||
"react-pdf": "^7.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-draggable": "^4.4.5",
|
||||
"react-hook-form": "^7.41.5",
|
||||
"react-pdf": "^6.2.2",
|
||||
"react-resizable": "^3.0.4",
|
||||
"react-tooltip": "^5.7.2",
|
||||
"short-uuid": "^4.2.2",
|
||||
"string-to-color": "^2.2.2"
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.8.0",
|
||||
"typescript": "5.0.4",
|
||||
"zod": "^3.21.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",
|
||||
"postcss": "^8.4.19",
|
||||
"sass": "^1.57.1",
|
||||
"stripe-cli": "^0.1.0",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "4.8.4"
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/node": "20.1.0",
|
||||
"@types/react": "18.2.6",
|
||||
"@types/react-dom": "18.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 couldn’t find the page you’re 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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"]
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -1 +0,0 @@
|
||||
export { checkoutSessionHandler as default } from '@documenso/lib/stripe/handlers/checkout-session'
|
||||
@ -1 +0,0 @@
|
||||
export { portalSessionHandler as default } from "@documenso/lib/stripe/handlers/portal-session";
|
||||
@ -1 +0,0 @@
|
||||
export { getSubscriptionHandler as default } from '@documenso/lib/stripe/handlers/get-subscription'
|
||||
@ -1,5 +0,0 @@
|
||||
export const config = {
|
||||
api: { bodyParser: false },
|
||||
};
|
||||
|
||||
export { webhookHandler as default } from "@documenso/lib/stripe/handlers/webhook";
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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) }),
|
||||
});
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
import SettingsPage from ".";
|
||||
|
||||
export default SettingsPage;
|
||||
@ -1 +0,0 @@
|
||||
export { default } from ".";
|
||||
@ -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;
|
||||
@ -1,3 +0,0 @@
|
||||
import SettingsPage from ".";
|
||||
|
||||
export default SettingsPage;
|
||||
@ -1,3 +0,0 @@
|
||||
import SettingsPage from ".";
|
||||
|
||||
export default SettingsPage;
|
||||
@ -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 : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
29
apps/web/process-env.d.ts
vendored
@ -1,24 +1,15 @@
|
||||
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;
|
||||
NEXT_PUBLIC_SITE_URL?: 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;
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||
|
||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
|
||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/web/public/documenso-supporter-pledge.pdf
Normal file
BIN
apps/web/public/fonts/caveat.ttf
Normal file
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 29 KiB |
1
apps/web/public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/opengraph-image.jpg
Normal file
|
After Width: | Height: | Size: 693 KiB |
1
apps/web/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
After Width: | Height: | Size: 629 B |
41
apps/web/src/api/claim-plan/fetcher.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
|
||||
|
||||
export const claimPlan = async ({
|
||||
name,
|
||||
email,
|
||||
planId,
|
||||
signatureDataUrl,
|
||||
signatureText,
|
||||
}: TClaimPlanRequestSchema) => {
|
||||
const response = await fetch('/api/claim-plan', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email,
|
||||
planId,
|
||||
signatureDataUrl,
|
||||
signatureText,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Failed to claim plan');
|
||||
}
|
||||
|
||||
const safeBody = ZClaimPlanResponseSchema.safeParse(body);
|
||||
|
||||
if (!safeBody.success) {
|
||||
throw new Error('Failed to claim plan');
|
||||
}
|
||||
|
||||
if ('error' in safeBody.data) {
|
||||
throw new Error(safeBody.data.error);
|
||||
}
|
||||
|
||||
return safeBody.data.redirectUrl;
|
||||
};
|
||||
37
apps/web/src/api/claim-plan/types.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZClaimPlanRequestSchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.transform((value) => value.toLowerCase()),
|
||||
name: z.string(),
|
||||
planId: z.string(),
|
||||
})
|
||||
.and(
|
||||
z.union([
|
||||
z.object({
|
||||
signatureDataUrl: z.string().min(1),
|
||||
signatureText: z.null(),
|
||||
}),
|
||||
z.object({
|
||||
signatureDataUrl: z.null(),
|
||||
signatureText: z.string().min(1),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
export type TClaimPlanRequestSchema = z.infer<typeof ZClaimPlanRequestSchema>;
|
||||
|
||||
export const ZClaimPlanResponseSchema = z
|
||||
.object({
|
||||
redirectUrl: z.string(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export type TClaimPlanResponseSchema = z.infer<typeof ZClaimPlanResponseSchema>;
|
||||
34
apps/web/src/api/document/create/fetcher.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { TCreateDocumentRequestSchema, ZCreateDocumentResponseSchema } from './types';
|
||||
|
||||
export const useCreateDocument = () => {
|
||||
return useMutation(async ({ file }: TCreateDocumentRequestSchema) => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.set('file', file);
|
||||
|
||||
const response = await fetch('/api/document/create', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Failed to create document');
|
||||
}
|
||||
|
||||
const safeBody = ZCreateDocumentResponseSchema.safeParse(body);
|
||||
|
||||
if (!safeBody.success) {
|
||||
throw new Error('Failed to create document');
|
||||
}
|
||||
|
||||
if ('error' in safeBody.data) {
|
||||
throw new Error(safeBody.data.error);
|
||||
}
|
||||
|
||||
return safeBody.data;
|
||||
});
|
||||
};
|
||||
19
apps/web/src/api/document/create/types.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateDocumentRequestSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
});
|
||||
|
||||
export type TCreateDocumentRequestSchema = z.infer<typeof ZCreateDocumentRequestSchema>;
|
||||
|
||||
export const ZCreateDocumentResponseSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export type TCreateDocumentResponseSchema = z.infer<typeof ZCreateDocumentResponseSchema>;
|
||||
94
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Clock, File, FileCheck } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { UploadDocument } from './upload-document';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const [stats, results] = await Promise.all([
|
||||
getStats({
|
||||
userId: session.id,
|
||||
}),
|
||||
findDocuments({
|
||||
userId: session.id,
|
||||
perPage: 10,
|
||||
}).then((r) => ({ ...r, data: [] })),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<CardMetric icon={FileCheck} title="Completed" value={stats.COMPLETED} />
|
||||
<CardMetric icon={File} title="Drafts" value={stats.DRAFT} />
|
||||
<CardMetric icon={Clock} title="Pending" value={stats.PENDING} />
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<UploadDocument />
|
||||
|
||||
<h2 className="mt-8 text-2xl font-semibold">Recent Documents</h2>
|
||||
|
||||
<div className="mt-8 overflow-x-auto rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{results.data.map((document) => (
|
||||
<TableRow key={document.id}>
|
||||
<TableCell className="font-medium">{document.id}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/documents/${document.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{document.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DocumentStatus status={document.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<LocaleDate date={document.created} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{results.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/app/(dashboard)/dashboard/upload-document.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCreateDocument } from '~/api/document/create/fetcher';
|
||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { isLoading, mutateAsync: createDocument } = useCreateDocument();
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
const { id } = await createDocument({
|
||||
file: file,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Document uploaded',
|
||||
description: 'Your document has been uploaded successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(`/documents/${id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while uploading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { PDFViewerProps } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
|
||||
|
||||
export type LoadablePDFCard = PDFViewerProps & {
|
||||
className?: string;
|
||||
pdfClassName?: string;
|
||||
};
|
||||
|
||||
const PDFCard = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
|
||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
||||
|
||||
<p className="mt-4 text-slate-500">Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
||||
return (
|
||||
<Card className={className} gradient {...props}>
|
||||
<CardContent className="p-2">
|
||||
<PDFCard className={pdfClassName} {...props} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
94
apps/web/src/app/(dashboard)/documents/[id]/page.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { LoadablePDFCard } from './loadable-pdf-card';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: session.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<h1
|
||||
className="mt-4 max-w-xs truncate text-2xl font-semibold md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
Document.pdf
|
||||
</h1>
|
||||
|
||||
<div className="mt-8 grid w-full grid-cols-12 gap-x-8">
|
||||
<LoadablePDFCard
|
||||
className="col-span-7 rounded-xl before:rounded-xl"
|
||||
document={document.document}
|
||||
/>
|
||||
|
||||
<div className="relative col-span-5">
|
||||
<div className="sticky top-20 flex max-h-screen min-h-[calc(100vh-6rem)] flex-col rounded-xl border bg-[hsl(var(--widget))] px-4 py-6">
|
||||
<h3 className="text-2xl font-semibold">Add Signers</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-black/30">Add the people who will sign the document.</p>
|
||||
|
||||
<hr className="mb-8 mt-4" />
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="">
|
||||
<p className="text-sm text-black/30">Add Signers (1/3)</p>
|
||||
|
||||
<div className="relative mt-4 h-[2px] rounded-md bg-slate-300">
|
||||
<div className="bg-primary absolute inset-y-0 left-0 w-1/3" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-x-4">
|
||||
<Button
|
||||
className="flex-1 bg-black/5 hover:bg-black/10"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<Button className="flex-1" size="lg">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
apps/web/src/app/(dashboard)/documents/data-table.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { Document } from '@documenso/prisma/client';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
export type DocumentsDataTableProps = {
|
||||
results: FindResultSet<Document>;
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Title',
|
||||
cell: ({ row }) => (
|
||||
<Link href={`/documents/${row.original.id}`} className="font-medium hover:underline">
|
||||
{row.original.title}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
|
||||
},
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'created',
|
||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
>
|
||||
{(table) => <DataTablePagination table={table} />}
|
||||
</DataTable>
|
||||
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
136
apps/web/src/app/(dashboard)/documents/page.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckCircle, Clock, Plus } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { isDocumentStatus } from '@documenso/lib/types/is-document-status';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { DocumentDropzone } from '~/components/(dashboard)/document-dropzone/document-dropzone';
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import {
|
||||
PeriodSelectorValue,
|
||||
isPeriodSelectorValue,
|
||||
} from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
status?: InternalDocumentStatus | 'ALL';
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const stats = await getStats({
|
||||
userId: session.id,
|
||||
});
|
||||
|
||||
const status = isDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
|
||||
const results = await findDocuments({
|
||||
userId: session.id,
|
||||
status: status === 'ALL' ? undefined : status,
|
||||
orderBy: {
|
||||
column: 'created',
|
||||
direction: 'desc',
|
||||
},
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
|
||||
const isNoResults = status === 'ALL' && period === '' && results.data.length === 0;
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
if (value === 'ALL') {
|
||||
params.delete('status');
|
||||
}
|
||||
|
||||
return `/documents?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">All Documents</h1>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
|
||||
<Tabs defaultValue={status}>
|
||||
<TabsList>
|
||||
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
|
||||
<Link href={getTabHref('ALL')}>All</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
|
||||
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats.DRAFT, 99)}
|
||||
</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.PENDING} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.PENDING)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.PENDING} />
|
||||
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats.PENDING, 99)}
|
||||
</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.COMPLETED} asChild>
|
||||
<Link href={getTabHref(InternalDocumentStatus.COMPLETED)}>
|
||||
<DocumentStatus status={InternalDocumentStatus.COMPLETED} />
|
||||
|
||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||
{Math.min(stats.COMPLETED, 99)}
|
||||
</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
|
||||
<Button>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{/* If we're viewing all documents for all time and there's nuffin we should should an add document component instead */}
|
||||
{isNoResults ? (
|
||||
<DocumentDropzone className="min-h-[60vh] md:min-h-[40vh]" />
|
||||
) : (
|
||||
<DocumentsDataTable results={results} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/app/(dashboard)/layout.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getServerSession } from 'next-auth';
|
||||
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import {
|
||||
getRequiredServerComponentSession,
|
||||
getServerComponentSession,
|
||||
} from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
export type AuthenticatedDashboardLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default async function AuthenticatedDashboardLayout({
|
||||
children,
|
||||
}: AuthenticatedDashboardLayoutProps) {
|
||||
const session = await getServerSession(NEXT_AUTH_OPTIONS);
|
||||
|
||||
if (!session) {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<Header user={user} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
29
apps/web/src/app/(dashboard)/settings/billing/page.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
// Redirect if subscriptions are not enabled.
|
||||
if (!IS_SUBSCRIPTIONS_ENABLED) {
|
||||
redirect('/settings/profile');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Billing</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Here you can update and manage your subscription.
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/(dashboard)/settings/layout.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { DesktopNav } from '~/components/(dashboard)/settings/layout/desktop-nav';
|
||||
import { MobileNav } from '~/components/(dashboard)/settings/layout/mobile-nav';
|
||||
|
||||
export type DashboardSettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">Settings</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
||||
<MobileNav className="col-span-12 mb-8 md:hidden" />
|
||||
|
||||
<div className="col-span-12 md:col-span-9">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
apps/web/src/app/(dashboard)/settings/page.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function SettingsPage() {
|
||||
redirect('/settings/profile');
|
||||
|
||||
// Page is intentionally empty because it will be redirected to /settings/profile
|
||||
return <div />;
|
||||
}
|
||||
19
apps/web/src/app/(dashboard)/settings/password/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export default async function PasswordSettingsPage() {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Password</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">Here you can update your password.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<PasswordForm user={user} className="max-w-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/app/(dashboard)/settings/profile/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
|
||||
import { ProfileForm } from '~/components/forms/profile';
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
const user = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Profile</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">Here you can edit your personal details.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<ProfileForm user={user} className="max-w-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/app/(unauthenticated)/signin/page.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
import connections from '~/assets/card-sharing-figure.png';
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||
<Image src={backgroundPattern} alt="background pattern" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-md">
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">Welcome back, we are lucky to have you.</p>
|
||||
|
||||
<SignInForm className="mt-4" />
|
||||
|
||||
<p className="mt-6 text-center text-sm text-slate-500">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 lg:block">
|
||||
<Image src={connections} alt="documenso connections" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/app/(unauthenticated)/signup/page.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
import connections from '~/assets/connections.png';
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||
<Image src={backgroundPattern} alt="background pattern" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-md">
|
||||
<h1 className="text-4xl font-semibold">Create a shiny, new Documenso Account ✨</h1>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Create your account and start using state-of-the-art document signing. Open and
|
||||
beautiful signing is within your grasp.
|
||||
</p>
|
||||
|
||||
<SignUpForm className="mt-4" />
|
||||
|
||||
<p className="mt-6 text-center text-sm text-slate-500">
|
||||
Already have an account?{' '}
|
||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign in instead
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 lg:block">
|
||||
<Image src={connections} alt="documenso connections" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
1
apps/web/src/app/globals.css
Normal file
@ -0,0 +1 @@
|
||||
@import '@documenso/ui/styles/theme.css';
|
||||
54
apps/web/src/app/layout.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
||||
|
||||
import { PlausibleProvider } from '~/providers/plausible';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
|
||||
export const metadata = {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
keywords:
|
||||
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
|
||||
authors: { name: 'Documenso, Inc.' },
|
||||
robots: 'index, follow',
|
||||
openGraph: {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
type: 'website',
|
||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
|
||||
<head>
|
||||
<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>
|
||||
<PlausibleProvider>
|
||||
<TrpcProvider>{children}</TrpcProvider>
|
||||
</PlausibleProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
3
apps/web/src/app/page.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function DashboardPage() {
|
||||
return <div>hello world</div>;
|
||||
}
|
||||
BIN
apps/web/src/assets/Group 1019.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/web/src/assets/background-pattern.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/web/src/assets/card-beautiful-figure.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/web/src/assets/card-build-figure.png
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
apps/web/src/assets/card-connections-figure.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/web/src/assets/card-fast-figure.png
Normal file
|
After Width: | Height: | Size: 27 KiB |