Compare commits

...

43 Commits

Author SHA1 Message Date
David Nguyen
ba95818da4 feat: update items 2023-08-21 12:17:56 +10:00
David Nguyen
d0720f4c70 feat: update items
Refactored billing flag name

Refactored FeatureFlag type

Disabled session recording by default
2023-08-21 12:17:56 +10:00
David Nguyen
f60cb22f11 feat: feature flags 2023-08-21 12:17:56 +10:00
Lucas Smith
e0cb4314fb Merge pull request #280 from nsylke/nsylke-patch-4
chore: ignore intellij editor settings and some of vscode
2023-08-19 10:53:11 +10:00
Nicholas Sylke
0571137a60 chore: ignore intellij editor settings and some of vscode 2023-08-18 18:41:06 -05:00
Timur Ercan
30aabf50eb Merge pull request #279 from documenso/feat/funding
chore: remove funding rounding
2023-08-18 17:50:51 +02:00
Timur Ercan
8441a5eb98 Merge branch 'feat/refresh' into feat/funding 2023-08-18 17:48:31 +02:00
Timur Ercan
259ab49bc1 chore: remove funding rounding 2023-08-18 17:47:36 +02:00
Lucas Smith
f633b17f17 Merge pull request #270 from Ashutosh-Bhadauriya/feat/add-commitlint
feat: add commitlint
2023-08-18 18:38:31 +10:00
Ashutosh-Bhadauriya
8fa16001e6 fix: commitlint 2023-08-18 13:49:46 +05:30
Ashutosh-Bhadauriya
e111234460 feat: add commitlint 2023-08-18 13:26:01 +05:30
Lucas Smith
a7664d79fd Merge pull request #260 from documenso/feat/document-authoring
feat: document authoring
2023-08-18 12:10:35 +10:00
Lucas Smith
8ed2393300 Merge branch 'feat/refresh' into feat/document-authoring 2023-08-18 12:07:50 +10:00
Lucas Smith
94cf150ffd Merge pull request #258 from nsylke/nsylke-patch-3
ci: create build workflow
2023-08-18 09:32:44 +10:00
Nicholas Sylke
c571a3d0d9 ci: use built-in cache from setup-node action 2023-08-17 18:17:27 -05:00
Lucas Smith
1c7431b859 Merge pull request #264 from documenso/fix/blog-post-pre-seed
Fix: typo in blog post `pre-seed`
2023-08-18 08:59:21 +10:00
Lucas Smith
2f7d6548ef Merge pull request #259 from documenso/feat/pie-chart-legend
feat: add legend to pie chart
2023-08-18 08:55:49 +10:00
flō
a16525be5e fix typo 2023-08-17 17:20:55 +02:00
flō
21e377d3ff fix typo for consistency 2023-08-17 16:46:42 +02:00
Timur Ercan
e098449af4 Merge pull request #262 from documenso/feat/funding
fix: link
2023-08-17 13:02:38 +02:00
Timur Ercan
47d0030cf0 fix: link 2023-08-17 13:02:21 +02:00
Ephraim Atta-Duncan
b564e5e72f feat: reduce chart radius to add padding 2023-08-17 10:57:36 +00:00
Timur Ercan
e46607c1cb Merge pull request #261 from documenso/feat/funding
Feat/funding
2023-08-17 12:56:04 +02:00
Timur Ercan
77c5db169a fix: format heade 2023-08-17 12:54:40 +02:00
Timur Ercan
0157bf9576 chore: adding open to footer, naming 2023-08-17 12:50:31 +02:00
Timur Ercan
454c2f45bd fix: open page link 2023-08-17 12:46:24 +02:00
Timur Ercan
badb897c06 chore: update date 2023-08-17 12:41:55 +02:00
Timur Ercan
9bb5768598 Merge branch 'feat/refresh' into feat/funding 2023-08-17 12:39:37 +02:00
Timur Ercan
951de8baf5 chore: review updates 2023-08-17 12:37:58 +02:00
Mythie
48ceb1e5c7 feat: document authoring 2023-08-17 19:56:18 +10:00
Ephraim Atta-Duncan
96e8962956 feat: change legend text color to black 2023-08-17 09:44:43 +00:00
Ephraim Atta-Duncan
f3259aedea feat: Add legend to pie chart 2023-08-17 08:24:37 +00:00
Lucas Smith
9fdc9dcbf7 Merge pull request #256 from nsylke/nsylke-patch-2
Add husky & lint staged
2023-08-17 11:07:18 +10:00
Nicholas Sylke
0a30403719 ci: remove --workspaces from the build script in ci workflow 2023-08-16 20:00:13 -05:00
Nicholas Sylke
0e1fcd86e6 ci: setup build workflow 2023-08-16 19:51:45 -05:00
Nicholas Sylke
8038f3ad00 fix: used wrong lockfile version when resolving conflicts 2023-08-16 13:00:50 -05:00
Timur Ercan
44369ee7f6 mention cals open startup article 2023-08-16 17:52:04 +02:00
Timur Ercan
7e46cb0d8e feat: pre seed announce and link on /open page 2023-08-16 17:42:27 +02:00
Nicholas Sylke
10e39246ce Merge branch 'feat/refresh' into nsylke-patch-2
# Conflicts:
#	package-lock.json
2023-08-16 07:34:40 -05:00
Timur Ercan
5e45767e44 Merge pull request #249 from documenso/feat/open-page
feat: add cap table to open page
2023-08-16 14:09:01 +02:00
Nicholas Sylke
6017d35cfd refactor: future proofing the prettier/lint-staged for js/ts filetypes 2023-08-16 06:27:45 -05:00
Nicholas Sylke
ba25ea1370 refactor: use lint-staged.config.cjs as configuration for lint-staged 2023-08-15 16:02:58 -05:00
Nicholas Sylke
f0c607d87a add husky and lint-staged to ensure commits are formatted 2023-08-14 16:14:53 -05:00
83 changed files with 4780 additions and 143 deletions

View File

@@ -52,7 +52,12 @@ NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
# [[FEATURES]]
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false
# OPTIONAL: Leave blank to disable PostHog and feature flags.
NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Defines the host to use for PostHog.
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
# OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# This is only required for the marketing site
# [[REDIS]]

29
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: "Continuous Integration"
on:
push:
branches: [ "feat/refresh" ]
pull_request:
branches: [ "feat/refresh" ]
env:
HUSKY: 0
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build

10
.gitignore vendored
View File

@@ -37,3 +37,13 @@ yarn-error.log*
# contentlayer
.contentlayer
# intellij
.idea
# vscode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

4
.husky/commit-msg Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run commitlint -- $1

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

View File

@@ -0,0 +1,36 @@
---
title: Announcing Pre-Seed and Open Metrics
description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2023-08-17
tags:
- Funding
- Metrics
- Open Startup
---
Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it.
## Two more for the road (to open signing)
We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso.
## Open Source, Open Metrics
If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com.
The two main takeaways are:
- "Any Startup that shares its metrics as open as technically and operationally possible is an Open Startup."
- "Why should I care? Frankly speaking, Open Startups have a tough time screwing you over."
The more open the culture, the less shady stuff is going on. While this may sound trivial, the implications are profound. A new generation of organizations, operating more ethically and responsibly simply because everything is out in the open.
For us, there are two sides to being an Open Startup:
- The company side: Sharing Financial KPIs like growth, funding, team structure, salary, internal processes, and tools.
- The product side: Sharing insights and data like usage, reach, and GitHub activity.
Both sides aim to contribute to the global knowledge base of how startups work, specifically COSS startups. As we see more and more COSS, best practices and business insights should be broadly available to let the space mature. As we contribute code to the global community, we also contribute our business knowledge to help bring about even more COSS.
Starting today, we're releasing a lot of our data as part of the Open Startup movement. You can find the juicy details on our funding and more here: [documen.so/open](https://documen.so/open)

View File

@@ -2,7 +2,7 @@
import { HTMLAttributes, useEffect, useState } from 'react';
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
import { cn } from '@documenso/ui/lib/utils';
@@ -60,8 +60,8 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
cy="50%"
labelLine={false}
label={renderCustomizedLabel}
outerRadius={180}
innerRadius={100}
outerRadius={160}
innerRadius={80}
fill="#8884d8"
dataKey="percentage"
>
@@ -69,6 +69,11 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Legend
formatter={(value) => {
return <span className="text-sm text-black">{value}</span>;
}}
/>
<Tooltip
formatter={(percent: number, name, props) => {
return [`${percent}%`, name || props['name'] || props['payload']['name']];

View File

@@ -56,11 +56,11 @@ export const FUNDING_RAISED = [
},
{
date: '2023-05',
amount: 300_000,
amount: 290_000,
},
{
date: '2023-07',
amount: 1_550_000,
amount: 1_540_000,
},
];

View File

@@ -70,9 +70,12 @@ export default async function OpenPage() {
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold lg:text-5xl">Open Startup</h1>
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
All our metrics, finances, and learnings are public. We believe in transparency and want
to share our journey with you.
to share our journey with you. You can read more about why here:{' '}
<a className="font-bold" href="https://documenso.com/blog/pre-seed" target="_blank">
Announcing Open Metrics
</a>
</p>
</div>

View File

@@ -46,10 +46,6 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
Blog
</Link>
<Link
href="/pricing"
className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]"
@@ -57,6 +53,14 @@ export const Footer = ({ className, ...props }: FooterProps) => {
Pricing
</Link>
<Link href="/blog" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
Blog
</Link>
<Link href="/open" className="flex-shrink-0 text-sm text-[#8D8D8D] hover:text-[#6D6D6D]">
Open
</Link>
<Link
href="https://status.documenso.com"
target="_blank"

View File

@@ -15,12 +15,16 @@ export const Header = ({ className, ...props }: HeaderProps) => {
</Link>
<div className="flex items-center gap-x-6">
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
Pricing
</Link>
<Link href="/blog" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
Blog
</Link>
<Link href="/pricing" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
Pricing
<Link href="/open" className="text-sm font-semibold text-[#8D8D8D] hover:text-[#6D6D6D]">
Open
</Link>
<Link

View File

@@ -27,6 +27,8 @@
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"perfect-freehand": "^1.2.0",
"posthog-js": "^1.75.3",
"posthog-node": "^3.1.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
@@ -34,6 +36,7 @@
"react-icons": "^4.8.0",
"react-pdf": "^7.1.1",
"react-rnd": "^10.4.1",
"ts-pattern": "^5.0.5",
"typescript": "5.1.6",
"zod": "^3.21.4"
},

View File

@@ -10,8 +10,6 @@ declare namespace NodeJS {
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
NEXT_PRIVATE_GOOGLE_CLIENT_ID: string;
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string;
}

View File

@@ -2,29 +2,15 @@
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { Loader } from 'lucide-react';
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields';
import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers';
import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject';
const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), {
ssr: false,
loading: () => (
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
</div>
),
});
export type EditDocumentFormProps = {
className?: string;
user: User;
@@ -71,7 +57,7 @@ export const EditDocumentForm = ({
gradient
>
<CardContent className="p-2">
<PDFViewer document={documentUrl} />
<LazyPDFViewer document={documentUrl} />
</CardContent>
</Card>

View File

@@ -8,6 +8,7 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Header } from '~/components/(dashboard)/layout/header';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
export type AuthenticatedDashboardLayoutProps = {
@@ -30,6 +31,8 @@ export default async function AuthenticatedDashboardLayout({
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<RefreshOnFocus />
</NextAuthProvider>
);
}

View File

@@ -1,15 +1,17 @@
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';
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
export default async function BillingSettingsPage() {
const user = await getRequiredServerComponentSession();
const isBillingEnabled = await getServerComponentFlag('app_billing');
// Redirect if subscriptions are not enabled.
if (!IS_SUBSCRIPTIONS_ENABLED) {
if (!isBillingEnabled) {
redirect('/settings/profile');
}

View File

@@ -0,0 +1,68 @@
'use client';
import { HTMLAttributes } from 'react';
import { Download } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
fileName?: string;
document?: string;
};
export const DownloadButton = ({
className,
fileName,
document,
disabled,
...props
}: DownloadButtonProps) => {
/**
* Convert the document from base64 to a blob and download it.
*/
const onDownloadClick = () => {
if (!document) {
return;
}
let decodedDocument = document;
try {
decodedDocument = atob(document);
} catch (err) {
// We're just going to ignore this error and try to download the document
console.error(err);
}
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
return (
<Button
type="button"
variant="outline"
className={className}
disabled={disabled || !document}
onClick={onDownloadClick}
{...props}
>
<Download className="mr-2 h-5 w-5" />
Download
</Button>
);
};

View File

@@ -0,0 +1,107 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8, Share } from 'lucide-react';
import { match } from 'ts-pattern';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { DownloadButton } from './download-button';
import { SigningCard } from './signing-card';
export type CompletedSigningPageProps = {
params: {
token?: string;
};
};
export default async function CompletedSigningPage({
params: { token },
}: CompletedSigningPageProps) {
if (!token) {
return notFound();
}
const document = await getDocumentAndSenderByToken({
token,
}).catch(() => null);
if (!document) {
return notFound();
}
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }),
]);
const recipientName =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
return (
<div className="flex flex-col items-center pt-24">
{/* Card with recipient */}
<SigningCard name={recipientName} />
<div className="mt-6">
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<div className="text-documenso-700 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm">Everyone has signed</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>
))}
</div>
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed "{document.title}"
</h2>
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
Everyone has signed! You will receive an Email copy of the signed document.
</p>
))
.otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
You will receive an Email copy of the signed document once everyone has signed.
</p>
))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
{/* TODO: Hook this up */}
<Button variant="outline" className="flex-1">
<Share className="mr-2 h-5 w-5" />
Share
</Button>
<DownloadButton
className="flex-1"
fileName={document.title}
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined}
disabled={document.status !== DocumentStatus.COMPLETED}
/>
</div>
<p className="text-muted-foreground/60 mt-36 text-sm">
Want so send slick signing links like this one?{' '}
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
Check out Documenso.
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import Image from 'next/image';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import signingCelebration from '~/assets/signing-celebration.png';
export type SigningCardProps = {
name: string;
};
export const SigningCard = ({ name }: SigningCardProps) => {
return (
<div className="relative w-full max-w-xs md:max-w-sm">
<Card
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
degrees={-145}
gradient
>
<CardContent
className="font-signature p-6 text-center"
style={{
container: 'main',
}}
>
<span
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
style={{
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
}}
>
{name}
</span>
</CardContent>
</Card>
<motion.div
className="absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
initial={{
opacity: 0,
scale: 0.8,
}}
animate={{
scale: 1,
opacity: 0.5,
}}
transition={{
delay: 0.5,
duration: 0.5,
}}
>
<Image
src={signingCelebration}
alt="background pattern"
className="w-full"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</motion.div>
</div>
);
};

View File

@@ -0,0 +1,94 @@
'use client';
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
export const DateField = ({ field, recipient }: DateFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const onSign = async () => {
try {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: '',
});
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Date</p>
)}
{field.inserted && (
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
)}
</SigningFieldContainer>
);
};

View File

@@ -0,0 +1,127 @@
'use client';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { Document, Field, Recipient } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '~/components/signature-pad';
import { useRequiredSigningContext } from './provider';
export type SigningFormProps = {
document: Document;
recipient: Recipient;
fields: Field[];
};
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const {
handleSubmit,
formState: { isSubmitting },
} = useForm();
const isComplete = fields.every((f) => f.inserted);
const onFormSubmit = async () => {
if (!isComplete) {
return;
}
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
});
router.push(`/sign/${recipient.token}/complete`);
};
return (
<form
className={cn(
'dark:bg-background border-border bg-widget sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border px-4 py-6',
)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
defaultValue={signature ?? undefined}
onChange={(value) => {
console.log({
signpadValue: value,
});
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
<div className="flex gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
size="lg"
disabled={!isComplete || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Complete
</Button>
</div>
</div>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';
export type SigningLayoutProps = {
children: React.ReactNode;
};
export default async function SigningLayout({ children }: SigningLayoutProps) {
const user = await getServerComponentSession();
return (
<NextAuthProvider>
<div className="min-h-screen overflow-hidden">
{user && <AuthenticatedHeader user={user} />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
</div>
</NextAuthProvider>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
export type NameFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
export const NameField = ({ field, recipient }: NameFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { fullName: providedFullName, setFullName: setProvidedFullName } =
useRequiredSigningContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showFullNameModal, setShowFullNameModal] = useState(false);
const [localFullName, setLocalFullName] = useState('');
const onSign = async (source: 'local' | 'provider' = 'provider') => {
try {
if (!providedFullName && !localFullName) {
setShowFullNameModal(true);
return;
}
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: source === 'local' && localFullName ? localFullName : providedFullName ?? '',
isBase64: false,
});
if (source === 'local' && !providedFullName) {
setProvidedFullName(localFullName);
}
setLocalFullName('');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Name</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
<DialogContent>
<DialogTitle>
Sign as {recipient.name}{' '}
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="py-4">
<Label htmlFor="signature">Full Name</Label>
<Input
type="text"
className="mt-2"
value={localFullName}
onChange={(e) => setLocalFullName(e.target.value)}
/>
</div>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowFullNameModal(false);
setLocalFullName('');
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localFullName}
onClick={() => {
setShowFullNameModal(false);
onSign('local');
}}
>
Sign
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@@ -0,0 +1,94 @@
import { notFound } from 'next/navigation';
import { match } from 'ts-pattern';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '~/components/(dashboard)/pdf-viewer/lazy-pdf-viewer';
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { DateField } from './date-field';
import { SigningForm } from './form';
import { NameField } from './name-field';
import { SigningProvider } from './provider';
import { SignatureField } from './signature-field';
export type SigningPageProps = {
params: {
token?: string;
};
};
export default async function SigningPage({ params: { token } }: SigningPageProps) {
if (!token) {
return notFound();
}
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }),
viewedDocument({ token }),
]);
if (!document) {
return notFound();
}
const documentUrl = `data:application/pdf;base64,${document.document}`;
return (
<SigningProvider email={recipient.email} fullName={recipient.name}>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to sign this document.
</p>
</div>
<div className="mt-8 grid grid-cols-12 gap-8">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={documentUrl} />
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm document={document} recipient={recipient} fields={fields} />
</div>
</div>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
<SignatureField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>
</div>
</SigningProvider>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { createContext, useContext, useState } from 'react';
export type SigningContextValue = {
fullName: string;
setFullName: (_value: string) => void;
email: string;
setEmail: (_value: string) => void;
signature: string | null;
setSignature: (_value: string | null) => void;
};
const SigningContext = createContext<SigningContextValue | null>(null);
export const useSigningContext = () => {
return useContext(SigningContext);
};
export const useRequiredSigningContext = () => {
const context = useSigningContext();
if (!context) {
throw new Error('Signing context is required');
}
return context;
};
export interface SigningProviderProps {
fullName?: string;
email?: string;
signature?: string;
children: React.ReactNode;
}
export const SigningProvider = ({
fullName: initialFullName,
email: initialEmail,
signature: initialSignature,
children,
}: SigningProviderProps) => {
const [fullName, setFullName] = useState(initialFullName || '');
const [email, setEmail] = useState(initialEmail || '');
const [signature, setSignature] = useState(initialSignature || null);
return (
<SigningContext.Provider
value={{
fullName,
setFullName,
email,
setEmail,
signature,
setSignature,
}}
>
{children}
</SigningContext.Provider>
);
};
SigningProvider.displayName = 'SigningProvider';

View File

@@ -0,0 +1,197 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SignaturePad } from '~/components/signature-pad';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type SignatureFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const { signature: providedSignature, setSignature: setProvidedSignature } =
useRequiredSigningContext();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation();
const { Signature: signature } = field;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showSignatureModal, setShowSignatureModal] = useState(false);
const [localSignature, setLocalSignature] = useState<string | null>(null);
const state = useMemo<SignatureFieldState>(() => {
if (!field.inserted) {
return 'empty';
}
if (signature?.signatureImageAsBase64) {
return 'signed-image';
}
return 'signed-text';
}, [field.inserted, signature?.signatureImageAsBase64]);
const onSign = async (source: 'local' | 'provider' = 'provider') => {
try {
console.log({
providedSignature,
localSignature,
});
if (!providedSignature && !localSignature) {
setShowSignatureModal(true);
return;
}
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: source === 'local' && localSignature ? localSignature : providedSignature ?? '',
isBase64: true,
});
if (source === 'local' && !providedSignature) {
setProvidedSignature(localSignature);
}
setLocalSignature(null);
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the signature.',
variant: 'destructive',
});
}
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
Signature
</p>
)}
{state === 'signed-image' && signature?.signatureImageAsBase64 && (
<img
src={signature.signatureImageAsBase64}
alt={`Signature for ${recipient.name}`}
className="h-full w-full object-contain"
/>
)}
{state === 'signed-text' && (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{signature?.typedSignature}
</p>
)}
<Dialog open={showSignatureModal} onOpenChange={setShowSignatureModal}>
<DialogContent>
<DialogTitle>
Sign as {recipient.name}{' '}
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="">
<Label htmlFor="signature">Signature</Label>
<SignaturePad
id="signature"
className="border-border mt-2 h-44 w-full rounded-md border"
onChange={(value) => setLocalSignature(value)}
/>
</div>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowSignatureModal(false);
setLocalSignature(null);
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localSignature}
onClick={() => {
setShowSignatureModal(false);
onSign('local');
}}
>
Sign
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@@ -0,0 +1,81 @@
'use client';
import React from 'react';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { useFieldPageCoords } from '~/hooks/use-field-page-coords';
export type SignatureFieldProps = {
field: FieldWithSignature;
loading?: boolean;
children: React.ReactNode;
onSign?: () => Promise<void> | void;
onRemove?: () => Promise<void> | void;
};
export const SigningFieldContainer = ({
field,
loading,
onSign,
onRemove,
children,
}: SignatureFieldProps) => {
const coords = useFieldPageCoords(field);
const onSignFieldClick = async () => {
if (field.inserted) {
return;
}
await onSign?.();
};
const onRemoveSignedFieldClick = async () => {
if (!field.inserted) {
return;
}
await onRemove?.();
};
return (
<div
className="absolute"
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<Card
className="bg-background relative h-full w-full"
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent
className={cn(
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
)}
>
{!field.inserted && !loading && (
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} />
)}
{field.inserted && !loading && (
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{children}
</CardContent>
</Card>
</div>
);
};

View File

@@ -1,15 +1,22 @@
import { Inter } from 'next/font/google';
import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
import { FeatureFlagProvider } from '~/providers/feature-flag';
import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog';
import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative',
@@ -35,9 +42,15 @@ export const metadata = {
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags();
return (
<html lang="en" className={fontInter.variable} suppressHydrationWarning>
<html
lang="en"
className={cn(fontInter.variable, fontCaveat.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" />
@@ -45,15 +58,21 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<link rel="manifest" href="/site.webmanifest" />
</head>
<Suspense>
<PostHogPageview />
</Suspense>
<body>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
<FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>
</body>
</html>
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

View File

@@ -12,7 +12,7 @@ export type StackAvatarProps = {
first?: boolean;
zIndex?: string;
fallbackText?: string;
type: 'unsigned' | 'waiting' | 'completed';
type: 'unsigned' | 'waiting' | 'opened' | 'completed';
};
export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarProps) => {
@@ -28,6 +28,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
case 'unsigned':
classes = 'bg-dawn-200 text-dawn-900';
break;
case 'opened':
classes = 'bg-yellow-200 text-yellow-700';
break;
case 'waiting':
classes = 'bg-water text-water-700';
break;

View File

@@ -13,15 +13,19 @@ import { StackAvatars } from './stack-avatars';
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => {
const waitingRecipients = recipients.filter(
(recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED',
(recipient) => getRecipientType(recipient) === 'waiting',
);
const openedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'opened',
);
const completedRecipients = recipients.filter(
(recipient) => recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED',
(recipient) => getRecipientType(recipient) === 'completed',
);
const uncompletedRecipients = recipients.filter(
(recipient) => recipient.sendStatus === 'NOT_SENT' && recipient.signingStatus === 'NOT_SIGNED',
(recipient) => getRecipientType(recipient) === 'unsigned',
);
return (
@@ -66,6 +70,23 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={initials(recipient.name)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
</div>
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">Uncompleted</h1>

View File

@@ -15,7 +15,6 @@ import {
import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
import { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@@ -28,11 +27,19 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useFeatureFlags } from '~/providers/feature-flag';
export type ProfileDropdownProps = {
user: User;
};
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const { theme, setTheme } = useTheme();
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
const initials =
user.name
?.split(' ')
@@ -40,8 +47,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
.slice(0, 2)
.join('') ?? 'UK';
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -69,7 +74,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</Link>
</DropdownMenuItem>
{IS_SUBSCRIPTIONS_ENABLED && (
{isBillingEnabled && (
<DropdownMenuItem asChild>
<Link href="/settings/billing" className="cursor-pointer">
<CreditCard className="mr-2 h-4 w-4" />

View File

@@ -0,0 +1,19 @@
'use client';
import dynamic from 'next/dynamic';
import { Loader } from 'lucide-react';
export const LazyPDFViewer = dynamic(
async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'),
{
ssr: false,
loading: () => (
<div className="dark:bg-background flex min-h-[80vh] flex-col items-center justify-center bg-white/50">
<Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p>
</div>
),
},
);

View File

@@ -0,0 +1,23 @@
'use client';
import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export const RefreshOnFocus = () => {
const { refresh } = useRouter();
const onFocus = useCallback(() => {
refresh();
}, [refresh]);
useEffect(() => {
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, [onFocus]);
return null;
};

View File

@@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useFeatureFlags } from '~/providers/feature-flag';
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link href="/settings/profile">
@@ -44,7 +49,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{IS_SUBSCRIPTIONS_ENABLED && (
{isBillingEnabled && (
<Link href="/settings/billing">
<Button
variant="ghost"

View File

@@ -7,15 +7,20 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { IS_SUBSCRIPTIONS_ENABLED } from '@documenso/lib/constants/features';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useFeatureFlags } from '~/providers/feature-flag';
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const pathname = usePathname();
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
return (
<div
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
@@ -47,7 +52,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{IS_SUBSCRIPTIONS_ENABLED && (
{isBillingEnabled && (
<Link href="/settings/billing">
<Button
variant="ghost"

View File

@@ -25,7 +25,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import { useToast } from '@documenso/ui/primitives/use-toast';
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { getBoundingClientRect } from '~/helpers/getBoundingClientRect';
import { getBoundingClientRect } from '~/helpers/get-bounding-client-rect';
import { addFields } from './add-fields.action';
import { TAddFieldsFormSchema } from './add-fields.types';

View File

@@ -1,3 +1,5 @@
'use client';
import React, { HTMLAttributes } from 'react';
import { Loader } from 'lucide-react';

View File

@@ -1,3 +1,5 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Trash } from 'lucide-react';

View File

@@ -1,53 +0,0 @@
import React, { createContext, useRef } from 'react';
import { OnPDFViewerPageClick } from '~/components/(dashboard)/pdf-viewer/pdf-viewer';
type EditFormContextValue = {
firePageClickEvent: OnPDFViewerPageClick;
registerPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
unregisterPageClickHandler: (_handler: OnPDFViewerPageClick) => void;
} | null;
const EditFormContext = createContext<EditFormContextValue>(null);
export type EditFormProviderProps = {
children: React.ReactNode;
};
export const useEditForm = () => {
const context = React.useContext(EditFormContext);
if (!context) {
throw new Error('useEditForm must be used within a EditFormProvider');
}
return context;
};
export const EditFormProvider = ({ children }: EditFormProviderProps) => {
const handlers = useRef(new Set<OnPDFViewerPageClick>());
const firePageClickEvent: OnPDFViewerPageClick = (event) => {
handlers.current.forEach((handler) => handler(event));
};
const registerPageClickHandler = (handler: OnPDFViewerPageClick) => {
handlers.current.add(handler);
};
const unregisterPageClickHandler = (handler: OnPDFViewerPageClick) => {
handlers.current.delete(handler);
};
return (
<EditFormContext.Provider
value={{
firePageClickEvent,
registerPageClickHandler,
unregisterPageClickHandler,
}}
>
{children}
</EditFormContext.Provider>
);
};

View File

@@ -1,5 +1,7 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
@@ -30,6 +32,8 @@ export type ProfileFormProps = {
};
export const ProfileForm = ({ className, user }: ProfileFormProps) => {
const router = useRouter();
const { toast } = useToast();
const {
@@ -59,6 +63,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
description: 'Your profile has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({

View File

@@ -0,0 +1,7 @@
'use client';
import { motion } from 'framer-motion';
export * from 'framer-motion';
export const MotionDiv = motion.div;

View File

@@ -24,7 +24,12 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
onChange?: (_signatureDataUrl: string | null) => void;
};
export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProps) => {
export const SignaturePad = ({
className,
defaultValue,
onChange,
...props
}: SignaturePadProps) => {
const $el = useRef<HTMLCanvasElement>(null);
const [isPressed, setIsPressed] = useState(false);
@@ -127,7 +132,7 @@ export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProp
setPoints(newPoints);
}
if ($el.current) {
if ($el.current && newPoints.length > 0) {
const ctx = $el.current.getContext('2d');
if (ctx) {
@@ -188,6 +193,23 @@ export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProp
}
}, []);
useEffect(() => {
console.log({ defaultValue });
if ($el.current && typeof defaultValue === 'string') {
const ctx = $el.current.getContext('2d');
const { width, height } = $el.current;
const img = new Image();
img.onload = () => {
ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height));
};
img.src = defaultValue;
}
}, [defaultValue]);
return (
<div className="relative block">
<canvas
@@ -202,10 +224,10 @@ export const SignaturePad = ({ className, onChange, ...props }: SignaturePadProp
{...props}
/>
<div className="absolute bottom-2 right-2">
<div className="absolute bottom-4 right-4">
<button
type="button"
className="focus-visible:ring-ring ring-offset-background rounded-full p-2 text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2"
className="focus-visible:ring-ring ring-offset-background rounded-full p-0 text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2"
onClick={() => onClearClick()}
>
Clear Signature

View File

@@ -0,0 +1,79 @@
import { z } from 'zod';
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag';
/**
* Evaluate whether a flag is enabled for the current user.
*
* @param flag The flag to evaluate.
* @param options See `GetFlagOptions`.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getFlag = async (
flag: string,
options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
const url = new URL(`${process.env.NEXT_PUBLIC_SITE_URL}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then((res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false);
return response;
};
/**
* Get all feature flags for the current user if possible.
*
* @param options See `GetFlagOptions`.
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getAllFlags = async (
options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${process.env.NEXT_PUBLIC_SITE_URL}/api/feature-flag/all`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then((res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
};
interface GetFlagOptions {
/**
* The headers to attach to the request to evaluate flags.
*
* The authenticated user will be derived from the headers if possible.
*/
requestHeaders: Record<string, string>;
}

View File

@@ -0,0 +1,16 @@
import { PostHog } from 'posthog-node';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export default function PostHogServerClient() {
const postHogConfig = extractPostHogConfig();
if (!postHogConfig) {
return null;
}
return new PostHog(postHogConfig.key, {
host: postHogConfig.host,
fetch: (...args) => fetch(...args),
});
}

View File

@@ -0,0 +1,26 @@
import { headers } from 'next/headers';
import { getAllFlags, getFlag } from './get-feature-flag';
/**
* Evaluate whether a flag is enabled for the current user in a server component.
*
* @param flag The flag to evaluate.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getServerComponentFlag = async (flag: string) => {
return await getFlag(flag, {
requestHeaders: Object.fromEntries(headers().entries()),
});
};
/**
* Get all feature flags for the current user from a server component.
*
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getServerComponentAllFlags = async () => {
return await getAllFlags({
requestHeaders: Object.fromEntries(headers().entries()),
});
};

View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useState } from 'react';
import { Field } from '@documenso/prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types';
import { getBoundingClientRect } from '~/helpers/get-bounding-client-rect';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
// X and Y are percentages of the page's height and width
const fieldX = (Number(field.positionX) / 100) * width + left;
const fieldY = (Number(field.positionY) / 100) * height + top;
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const observer = new ResizeObserver(() => {
calculateCoords();
});
observer.observe($page);
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
return coords;
};

View File

@@ -48,7 +48,7 @@ export default async function handler(
// We had intended to do this with Zod but we can only validate it
// as a persistent file which does not include the properties that we
// need.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
resolve({ ...fields, ...files } as any);
});
},

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
export const config = {
runtime: 'edge',
};
/**
* Get all the evaluated feature flags based on the current user if possible.
*/
export default async function handler(req: Request) {
const requestHeaders = Object.fromEntries(req.headers.entries());
const nextReq = new NextRequest(req, {
headers: requestHeaders,
});
const token = await getToken({ req: nextReq });
const postHog = PostHogServerClient();
// Return the local feature flags if PostHog is not enabled, true by default.
// The front end should not call this API if PostHog is not enabled to reduce network requests.
if (!postHog) {
return NextResponse.json(LOCAL_FEATURE_FLAGS);
}
const distinctId = extractDistinctUserId(token, nextReq);
const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token));
const res = NextResponse.json(featureFlags);
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
return res;
}

View File

@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid';
import { JWT, getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
export const config = {
runtime: 'edge',
};
/**
* Evaluate a single feature flag based on the current user if possible.
*
* @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name
* @returns A Response with the feature flag value.
*/
export default async function handler(req: Request) {
const { searchParams } = new URL(req.url ?? '');
const flag = searchParams.get('flag');
const requestHeaders = Object.fromEntries(req.headers.entries());
const nextReq = new NextRequest(req, {
headers: requestHeaders,
});
const token = await getToken({ req: nextReq });
if (!flag) {
return NextResponse.json(
{
error: 'Missing flag query parameter.',
},
{
status: 400,
headers: {
'content-type': 'application/json',
},
},
);
}
const postHog = PostHogServerClient();
// Return the local feature flags if PostHog is not enabled, true by default.
// The front end should not call this API if PostHog is disabled to reduce network requests.
if (!postHog) {
return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true);
}
const distinctId = extractDistinctUserId(token, nextReq);
const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token));
const res = NextResponse.json(featureFlag);
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
return res;
}
/**
* Map a JWT to properties which are consumed by PostHog to evaluate feature flags.
*
* @param jwt The JWT of the current user.
* @returns A map of properties which are consumed by PostHog.
*/
export const mapJwtToFlagProperties = (
jwt?: JWT | null,
): {
groups?: Record<string, string>;
personProperties?: Record<string, string>;
groupProperties?: Record<string, Record<string, string>>;
} => {
return {
personProperties: {
email: jwt?.email ?? '',
},
groupProperties: {
// Add properties to group users into different groups, such as billing plan.
},
};
};
/**
* Extract a distinct ID from a JWT and request.
*
* Will fallback to a random ID if no ID could be extracted from either the JWT or request.
*
* @param jwt The JWT of the current user.
* @param request Request potentially containing a PostHog `distinct_id` cookie.
* @returns A distinct user ID.
*/
export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => {
const config = extractPostHogConfig();
const email = jwt?.email;
const userId = jwt?.id.toString();
let fallbackDistinctId = nanoid();
if (config) {
try {
const postHogCookie = JSON.parse(
request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '',
);
const postHogDistinctId = postHogCookie['distinct_id'];
if (typeof postHogDistinctId === 'string') {
fallbackDistinctId = postHogDistinctId;
}
} catch {
// Do nothing.
}
}
return email ?? userId ?? fallbackDistinctId;
};

View File

@@ -0,0 +1,105 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { z } from 'zod';
import {
FEATURE_FLAG_POLL_INTERVAL,
LOCAL_FEATURE_FLAGS,
isFeatureFlagEnabled,
} from '@documenso/lib/constants/feature-flags';
import { getAllFlags } from '~/helpers/get-feature-flag';
export const ZFeatureFlagValueSchema = z.union([
z.boolean(),
z.string(),
z.number(),
z.undefined(),
]);
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;
export type FeatureFlagContextValue = {
getFlag: (_key: string) => TFeatureFlagValue;
};
export const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
export const useFeatureFlags = () => {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
}
return context;
};
export function FeatureFlagProvider({
children,
initialFlags,
}: {
children: React.ReactNode;
initialFlags: Record<string, TFeatureFlagValue>;
}) {
const [flags, setFlags] = useState(initialFlags);
const getFlag = useCallback(
(flag: string) => {
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
return flags[flag] ?? false;
},
[flags],
);
/**
* Refresh the flags every `FEATURE_FLAG_POLL_INTERVAL` amount of time if the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const interval = setInterval(() => {
if (document.hasFocus()) {
getAllFlags().then((newFlags) => setFlags(newFlags));
}
}, FEATURE_FLAG_POLL_INTERVAL);
return () => {
clearInterval(interval);
};
}, []);
/**
* Refresh the flags when the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const onFocus = () => getAllFlags().then((newFlags) => setFlags(newFlags));
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
return (
<FeatureFlagContext.Provider
value={{
getFlag,
}}
>
{children}
</FeatureFlagContext.Provider>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { getSession } from 'next-auth/react';
import posthog from 'posthog-js';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export function PostHogPageview() {
const postHogConfig = extractPostHogConfig();
const pathname = usePathname();
const searchParams = useSearchParams();
if (typeof window !== 'undefined' && postHogConfig) {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
disable_session_recording: true,
loaded: () => {
getSession()
.then((session) => {
if (session) {
posthog.identify(session.user.email ?? session.user.id.toString());
} else {
posthog.reset();
}
})
.catch(() => {
// Do nothing.
});
},
});
}
useEffect(() => {
if (!postHogConfig || !pathname) {
return;
}
let url = window.origin + pathname;
if (searchParams && searchParams.toString()) {
url = url + `?${searchParams.toString()}`;
}
posthog.capture('$pageview', {
$current_url: url,
});
}, [pathname, searchParams, postHogConfig]);
return null;
}

3
commitlint.config.cjs Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
};

View File

@@ -22,6 +22,9 @@ RUN apk add --no-cache libc6-compat
# Copy our current monorepo
COPY . .
# Disable husky from installing hooks
ENV HUSKY 0
RUN npm ci
RUN npm run build --workspaces
@@ -47,4 +50,4 @@ EXPOSE 3000
ENV PORT 3000
CMD ["npm", "run", "start"]
CMD ["npm", "run", "start"]

3
lint-staged.config.cjs Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
'**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'],
};

2092
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,19 @@
"dev": "turbo run dev --filter=@documenso/{web,marketing}",
"start": "cd apps && cd web && next start",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
"prepare": "husky install",
"commitlint": "commitlint --edit"
},
"devDependencies": {
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"dotenv": "^16.0.3",
"dotenv-cli": "^7.2.1",
"eslint": "^7.32.0",
"eslint-config-custom": "*",
"husky": "^8.0.0",
"lint-staged": "^14.0.0",
"prettier": "^2.5.1",
"turbo": "^1.9.3"
},

View File

@@ -29,7 +29,7 @@ module.exports = {
rules: {
'react/no-unescaped-entities': 'off',
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
// We never want to use `as` but are required to on occasion to handle

View File

@@ -1,10 +1,21 @@
import { Recipient } from '@documenso/prisma/client';
import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientType = (recipient: Recipient) => {
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'SIGNED') {
if (
recipient.sendStatus === SendStatus.SENT &&
recipient.signingStatus === SigningStatus.SIGNED
) {
return 'completed';
}
if (
recipient.sendStatus === SendStatus.SENT &&
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
) {
return 'opened';
}
if (recipient.sendStatus === 'SENT' && recipient.signingStatus === 'NOT_SIGNED') {
return 'waiting';
}

View File

@@ -0,0 +1,37 @@
/**
* How frequent to poll for new feature flags in milliseconds.
*/
export const FEATURE_FLAG_POLL_INTERVAL = 30000;
/**
* Feature flags that will be used when PostHog is disabled.
*
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
} as const;
/**
* Extract the PostHog configuration from the environment.
*/
export function extractPostHogConfig(): { key: string; host: string } | null {
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const postHogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
if (!postHogKey || !postHogHost) {
return null;
}
return {
key: postHogKey,
host: postHogHost,
};
}
/**
* Whether feature flags are enabled for the current instance.
*/
export function isFeatureFlagEnabled(): boolean {
return extractPostHogConfig() !== null;
}

View File

@@ -1,5 +0,0 @@
/* eslint-disable turbo/no-undeclared-env-vars */
export const IS_SUBSCRIPTIONS_ENABLED = process.env.NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED === 'true';
export const isSubscriptionsEnabled = () =>
process.env.NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED === 'true';

View File

@@ -1,6 +1,9 @@
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
import { headers } from 'next/headers';
import { NextRequest } from 'next/server';
import { getServerSession as getNextAuthServerSession } from 'next-auth';
import { getToken } from 'next-auth/jwt';
import { prisma } from '@documenso/prisma';
@@ -27,6 +30,18 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
return user;
};
export const getServerComponentToken = async () => {
const requestHeaders = Object.fromEntries(headers().entries());
const req = new NextRequest('http://example.com', {
headers: requestHeaders,
});
const token = await getToken({
req,
});
};
export const getServerComponentSession = async () => {
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);

View File

@@ -10,23 +10,25 @@
"universal/",
"next-auth/"
],
"scripts": {
},
"scripts": {},
"dependencies": {
"@documenso/email": "*",
"@documenso/prisma": "*",
"@pdf-lib/fontkit": "^1.1.1",
"@next-auth/prisma-adapter": "1.0.7",
"@pdf-lib/fontkit": "^1.1.1",
"@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0",
"pdf-lib": "^1.17.1",
"luxon": "^3.4.0",
"nanoid": "^4.0.2",
"next": "13.4.12",
"next-auth": "4.22.3",
"pdf-lib": "^1.17.1",
"react": "18.2.0",
"stripe": "^12.7.0"
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0"
"@types/bcrypt": "^5.0.0",
"@types/luxon": "^3.3.1"
}
}

View File

@@ -0,0 +1,92 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { sealDocument } from './seal-document';
export type CompleteDocumentWithTokenOptions = {
token: string;
documentId: number;
};
export const completeDocumentWithToken = async ({
token,
documentId,
}: CompleteDocumentWithTokenOptions) => {
'use server';
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
some: {
token,
},
},
},
include: {
Recipient: {
where: {
token,
},
},
},
});
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
if (document.Recipient.length === 0) {
throw new Error(`Document ${document.id} has no recipient with token ${token}`);
}
const [recipient] = document.Recipient;
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
recipientId: recipient.id,
},
});
if (fields.some((field) => !field.inserted)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
});
const documents = await prisma.document.updateMany({
where: {
id: document.id,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
data: {
status: DocumentStatus.COMPLETED,
},
});
console.log('documents', documents);
if (documents.count > 0) {
console.log('sealing document');
sealDocument({ documentId: document.id });
}
};

View File

@@ -0,0 +1,30 @@
import { prisma } from '@documenso/prisma';
export interface GetDocumentAndSenderByTokenOptions {
token: string;
}
export const getDocumentAndSenderByToken = async ({
token,
}: GetDocumentAndSenderByTokenOptions) => {
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
include: {
User: true,
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...User } = result.User;
return {
...result,
User,
};
};

View File

@@ -0,0 +1,74 @@
'use server';
import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
export type SealDocumentOptions = {
documentId: number;
};
export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
'use server';
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
});
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) {
throw new Error(`Document ${document.id} has unsigned recipients`);
}
const fields = await prisma.field.findMany({
where: {
documentId: document.id,
},
include: {
Signature: true,
},
});
if (fields.some((field) => !field.inserted)) {
throw new Error(`Document ${document.id} has unsigned fields`);
}
// !: Need to write the fields onto the document as a hard copy
const { document: pdfData } = document;
const doc = await PDFDocument.load(pdfData);
for (const field of fields) {
console.log('inserting field', {
...field,
Signature: null,
});
await insertFieldInPDF(doc, field);
}
const pdfBytes = await doc.save();
await prisma.document.update({
where: {
id: document.id,
status: DocumentStatus.COMPLETED,
},
data: {
document: Buffer.from(pdfBytes).toString('base64'),
},
});
};

View File

@@ -48,12 +48,15 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
return;
}
const assetBaseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_SITE_URL}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,
assetBaseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
inviterName: user.name || undefined,
inviterEmail: user.email,
signDocumentLink: 'https://example.com',
assetBaseUrl,
signDocumentLink,
});
mailer.sendMail({

View File

@@ -0,0 +1,29 @@
import { prisma } from '@documenso/prisma';
import { ReadStatus } from '@documenso/prisma/client';
export type ViewedDocumentOptions = {
token: string;
};
export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
readStatus: ReadStatus.NOT_OPENED,
},
});
if (!recipient) {
console.warn(`No recipient found for token ${token}`);
return;
}
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
readStatus: ReadStatus.OPENED,
},
});
};

View File

@@ -0,0 +1,18 @@
import { prisma } from '@documenso/prisma';
export type GetFieldsForTokenOptions = {
token: string;
};
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
return await prisma.field.findMany({
where: {
Recipient: {
token,
},
},
include: {
Signature: true,
},
});
};

View File

@@ -0,0 +1,59 @@
'use server';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
export type RemovedSignedFieldWithTokenOptions = {
token: string;
fieldId: number;
};
export const removeSignedFieldWithToken = async ({
token,
fieldId,
}: RemovedSignedFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
Recipient: {
token,
},
},
include: {
Document: true,
Recipient: true,
},
});
const { Document: document, Recipient: recipient } = field;
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
// Unreachable code based on the above query but we need to satisfy TypeScript
if (field.recipientId === null) {
throw new Error(`Field ${fieldId} has no recipientId`);
}
await Promise.all([
prisma.field.update({
where: {
id: field.id,
},
data: {
customText: '',
inserted: false,
},
}),
prisma.signature.deleteMany({
where: {
fieldId: field.id,
},
}),
]);
};

View File

@@ -0,0 +1,89 @@
'use server';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
export type SignFieldWithTokenOptions = {
token: string;
fieldId: number;
value: string;
isBase64?: boolean;
};
export const signFieldWithToken = async ({
token,
fieldId,
value,
isBase64,
}: SignFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
Recipient: {
token,
},
},
include: {
Document: true,
Recipient: true,
},
});
const { Document: document, Recipient: recipient } = field;
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (field.inserted) {
throw new Error(`Field ${fieldId} has already been inserted`);
}
// Unreachable code based on the above query but we need to satisfy TypeScript
if (field.recipientId === null) {
throw new Error(`Field ${fieldId} has no recipientId`);
}
const isSignatureField =
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
let customText = !isSignatureField ? value : undefined;
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
if (field.type === FieldType.DATE) {
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
}
await prisma.field.update({
where: {
id: field.id,
},
data: {
customText,
inserted: true,
Signature: isSignatureField
? {
upsert: {
create: {
recipientId: field.recipientId,
signatureImageAsBase64,
typedSignature,
},
update: {
recipientId: field.recipientId,
signatureImageAsBase64,
typedSignature,
},
},
}
: undefined,
},
});
};

View File

@@ -0,0 +1,144 @@
import fontkit from '@pdf-lib/fontkit';
import { readFileSync } from 'fs';
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { FieldType } from '@documenso/prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
const DEFAULT_STANDARD_FONT_SIZE = 15;
const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit);
const fontCaveat = readFileSync('./public/fonts/caveat.ttf');
const pages = pdf.getPages();
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
let fontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
const page = pages.at(field.page - 1);
if (!page) {
throw new Error(`Page ${field.page} does not exist`);
}
const { width: pageWidth, height: pageHeight } = page.getSize();
const fieldWidth = pageWidth * (Number(field.width) / 100);
const fieldHeight = pageHeight * (Number(field.height) / 100);
const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100);
console.log({
fieldWidth,
fieldHeight,
fieldX,
fieldY,
pageWidth,
pageHeight,
});
const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await pdf.embedFont(fontCaveat);
}
const isInsertingImage =
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string';
if (isSignatureField && isInsertingImage) {
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
let imageWidth = image.width;
let imageHeight = image.height;
const initialDimensions = {
width: imageWidth,
height: imageHeight,
};
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
const imageX = fieldX + (fieldWidth - imageWidth) / 2;
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system
imageY = pageHeight - imageY - imageHeight;
console.log({
initialDimensions,
scalingFactor,
imageWidth,
imageHeight,
imageX,
imageY,
});
page.drawImage(image, {
x: imageX,
y: imageY,
width: imageWidth,
height: imageHeight,
});
} else {
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textHeight = font.heightAtSize(fontSize);
const initialDimensions = {
width: textWidth,
height: textHeight,
};
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
fontSize = Math.max(fontSize * scalingFactor, maxFontSize);
textWidth = font.widthOfTextAtSize(field.customText, fontSize);
const textX = fieldX + (fieldWidth - textWidth) / 2;
let textY = fieldY + (fieldHeight - textHeight) / 2;
console.log({
initialDimensions,
scalingFactor,
textWidth,
textHeight,
textX,
textY,
pageWidth,
pageHeight,
});
// Invert the Y axis since PDFs use a bottom-left coordinate system
textY = pageHeight - textY - textHeight;
page.drawText(field.customText, {
x: textX,
y: textY,
size: fontSize,
font,
});
}
return pdf;
};
export const insertFieldInPDFBytes = async (
pdf: ArrayBuffer | Uint8Array | string,
field: FieldWithSignature,
) => {
const pdfDoc = await PDFDocument.load(pdf);
await insertFieldInPDF(pdfDoc, field);
return await pdfDoc.save();
};

View File

@@ -0,0 +1,13 @@
import { prisma } from '@documenso/prisma';
export interface GetRecipientByTokenOptions {
token: string;
}
export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions) => {
return await prisma.recipient.findFirstOrThrow({
where: {
token,
},
});
};

View File

@@ -0,0 +1,9 @@
import { FieldType } from '@prisma/client';
const SignatureFieldTypes = [FieldType.SIGNATURE, FieldType.FREE_SIGNATURE] as const;
type SignatureFieldType = (typeof SignatureFieldTypes)[number];
export const isSignatureFieldType = (type: FieldType): type is SignatureFieldType => {
return type === FieldType.SIGNATURE || type === FieldType.FREE_SIGNATURE;
};

View File

@@ -0,0 +1,5 @@
import { Field, Signature } from '@documenso/prisma/client';
export type FieldWithSignature = Field & {
Signature?: Signature | null;
};

View File

@@ -9,6 +9,7 @@ module.exports = {
extend: {
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans],
signature: ['var(--font-signature)'],
},
colors: {
border: 'hsl(var(--border))',

View File

@@ -0,0 +1,54 @@
import { TRPCError } from '@trpc/server';
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { procedure, router } from '../trpc';
import {
ZRemovedSignedFieldWithTokenMutationSchema,
ZSignFieldWithTokenMutationSchema,
} from './schema';
export const fieldRouter = router({
signFieldWithToken: procedure
.input(ZSignFieldWithTokenMutationSchema)
.mutation(async ({ input }) => {
try {
const { token, fieldId, value, isBase64 } = input;
return await signFieldWithToken({
token,
fieldId,
value,
isBase64,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
});
}
}),
removeSignedFieldWithToken: procedure
.input(ZRemovedSignedFieldWithTokenMutationSchema)
.mutation(async ({ input }) => {
try {
const { token, fieldId } = input;
return await removeSignedFieldWithToken({
token,
fieldId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to remove the signature for this field. Please try again later.',
});
}
}),
});

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
export const ZSignFieldWithTokenMutationSchema = z.object({
token: z.string(),
fieldId: z.number(),
value: z.string(),
isBase64: z.boolean().optional(),
});
export type TSignFieldWithTokenMutationSchema = z.infer<typeof ZSignFieldWithTokenMutationSchema>;
export const ZRemovedSignedFieldWithTokenMutationSchema = z.object({
token: z.string(),
fieldId: z.number(),
});
export type TRemovedSignedFieldWithTokenMutationSchema = z.infer<
typeof ZRemovedSignedFieldWithTokenMutationSchema
>;

View File

@@ -1,5 +1,6 @@
import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router';
import { profileRouter } from './profile-router/router';
import { procedure, router } from './trpc';
@@ -8,6 +9,7 @@ export const appRouter = router({
auth: authRouter,
profile: profileRouter,
document: documentRouter,
field: fieldRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -13,8 +13,6 @@ declare namespace NodeJS {
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api';
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;

View File

@@ -0,0 +1,35 @@
'use client';
import React, { useEffect, useState } from 'react';
export type ElementVisibleProps = {
target: string;
children: React.ReactNode;
};
export const ElementVisible = ({ target, children }: ElementVisibleProps) => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const observer = new MutationObserver((_mutations) => {
const $el = document.querySelector(target);
setVisible(!!$el);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [target]);
if (!visible) {
return null;
}
return <>{children}</>;
};

View File

@@ -2,8 +2,13 @@
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
"dependsOn": [
"^build"
],
"outputs": [
".next/**",
"!.next/cache/**"
]
},
"lint": {},
"dev": {
@@ -11,14 +16,18 @@
"persistent": true
}
},
"globalDependencies": ["**/.env.*local"],
"globalDependencies": [
"**/.env.*local"
],
"globalEnv": [
"NEXTAUTH_URL",
"NEXTAUTH_SECRET",
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_POSTHOG_HOST",
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
"NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PRIVATE_SMTP_TRANSPORT",
@@ -37,4 +46,4 @@
"NEXT_PRIVATE_SMTP_FROM_NAME",
"NEXT_PRIVATE_SMTP_FROM_ADDRESS"
]
}
}