Compare commits

..

102 Commits

Author SHA1 Message Date
Catalin Pit
f9b2abcadd extend webhook triggers 2024-02-24 10:54:20 +02:00
Catalin Pit
99a26065a8 zapier webhooks 2024-02-23 15:02:53 +02:00
Catalin Pit
91375a17c2 chore: merged webhooks 2024-02-22 09:54:43 +02:00
Catalin Pit
a0aeca48f2 chore: merged api 2024-02-21 13:44:08 +02:00
Lucas Smith
df132a51ab feat: ability to download all the 2FA recovery codes (#944)
fixes #938 



https://github.com/documenso/documenso/assets/81948346/8bf807ae-41b0-417b-9f55-d96584e71acb
2024-02-21 13:39:08 +11:00
Lucas Smith
8287722f59 fix: update view dialog to use new download api 2024-02-21 02:29:19 +00:00
Lucas Smith
aba6b58c14 fix: simplify download api 2024-02-21 02:19:35 +00:00
Lucas Smith
b6c9213b66 fix: disable static generation of marketing site pages
Disables the generation of the blog and content pages using
generateStaticPaths to deal with a regression with routing
introduced with next-runtime-env.
2024-02-21 00:58:57 +00:00
Lucas Smith
22e3a79a72 Merge branch 'main' into feat/public-api 2024-02-21 11:29:36 +11:00
Mythie
b9e5905469 feat: create from template 2024-02-20 19:46:18 +11:00
Anik Dhabal Babu
39c6cbf66a feat: ability to download 2FA recovery codes 2024-02-19 11:25:15 +00:00
Catalin Pit
c6dbaaea21 Merge branch 'main' into feat/webhook-implementation 2024-02-19 10:02:06 +02:00
Anik Dhabal Babu
0186f2dfed feat: ability to download 2FA recovery codes 2024-02-17 13:19:03 +05:30
David Nguyen
2815b1a809 feat: add enterprise billing (#939)
## Description

Add support for enterprise billing plans.

Enterprise billing plans by default get access to everything early
adopters do:
- Unlimited teams
- Unlimited documents

They will also get additional features in the future.

## Notes

Pending webhook updates to support enterprise onboarding.
Rolled back env changes `NEXT_PUBLIC_PROJECT` since it doesn't seem to
work.
2024-02-17 12:42:00 +11:00
Anik Dhabal Babu
5d6cdbef89 feat: ability to download all the 2FA recovery codes 2024-02-16 20:46:27 +00:00
Catalin Pit
26d4bbf010 chore: ui updates 2024-02-16 13:58:03 +02:00
Lucas Smith
960914aeb5 fix: undo operation on signature pad (#868)
fixes: #864
2024-02-16 22:57:14 +11:00
Lucas Smith
d83769b410 chore: use unsafe effect 2024-02-16 11:56:02 +00:00
Catalin Pit
cd240ae8a4 chore: loading spinner 2024-02-16 13:55:47 +02:00
Catalin Pit
a1459b41fd Merge branch 'main' into feat/webhook-implementation 2024-02-16 13:04:38 +02:00
Lucas Smith
a0cf2a2c75 fix: improved document-dropzone ui for small vertical screens (#857)
improved document-dropzone ui for small vertical screens (screens less
than 800px vertically)
Although it can still become congested on really small vertical screens,
but possibility is really low.

fixes: #840
2024-02-16 22:03:24 +11:00
Lucas Smith
a30b73ce86 fix: update css 2024-02-16 11:02:04 +00:00
Lucas Smith
46d163d9d6 fix: highlighting issue in recipient selection (#937)
fixes: #920 

<img width="391" alt="image"
src="https://github.com/documenso/documenso/assets/75713174/08b2f5ab-4a6f-423a-a2fa-8f7b04789bb8">
2024-02-16 21:50:53 +11:00
Lucas Smith
681a89cfe1 chore: minor lint fixes (#934) 2024-02-16 21:48:45 +11:00
Catalin Pit
4d6e780abe chore: merge main 2024-02-16 12:12:54 +02:00
Catalin Pit
7f3f6f5312 feat: hide secret field 2024-02-16 11:44:03 +02:00
Catalin Pit
019db27b1d feat: trigger webhook functionality 2024-02-16 11:04:11 +02:00
Lucas Smith
e5f4edc120 chore: create security.txt (#878)
Adding a security.txt file enables security researchers to quickly and
easily see where they can submit security issues and know that they are
being taken serious. From the proposal website:

> "When security risks in web services are discovered by independent
security researchers who understand the severity of the risk, they often
lack the channels to disclose them properly. As a result, security
issues may be left unreported. security.txt defines a standard to help
organizations define the process for security researchers to disclose
security vulnerabilities securely.”

See also https://securitytxt.org
2024-02-16 12:34:41 +11:00
Sumit Bisht
25291b64eb fix: highlighting issue in recipient selection 2024-02-15 22:25:23 +05:30
Lucas Smith
fe2093fe7c feat: add next-runtime-env (#869)
This PR adds the package
[next-runtime-env](https://github.com/expatfile/next-runtime-env/) to
populate the public environment variables at runtime.
2024-02-15 22:10:21 +11:00
Ephraim Atta-Duncan
49cddfab38 chore: lint with oxc 2024-02-15 06:11:50 +00:00
Timur Ercan
3e12a05ab8 chore: more grammar 2024-02-14 17:19:48 +01:00
Timur Ercan
a76504c0a4 Merge branch 'main' into chore-security-text 2024-02-14 17:16:44 +01:00
Timur Ercan
abab0c0a22 chore: grammer and format 2024-02-14 17:14:43 +01:00
Catalin Pit
61958989b4 feat: more webhook functionality 2024-02-14 14:38:58 +02:00
Mythie
4c5b910a59 chore: add examples 2024-02-14 13:15:35 +11:00
Mythie
1a82740d0f feat: support recipient roles 2024-02-12 15:16:09 +11:00
Lucas Smith
51608ed390 fix: lint issue 2024-02-12 02:02:43 +00:00
Lucas Smith
8ebef831ac Merge branch 'main' into feat/add-runtime-env 2024-02-12 12:30:35 +11:00
Lucas Smith
20e2976731 fix: build issues 2024-02-12 01:29:22 +00:00
Catalin Pit
0209127136 feat: delete webhook functionality 2024-02-09 16:28:18 +02:00
Catalin Pit
ddb9dd11d7 feat: added backend stuff 2024-02-09 16:07:33 +02:00
Catalin Pit
b3ba77dfed feat: allow user to choose expiry date 2024-02-09 11:35:09 +02:00
Lucas Smith
e91bb78f2d Merge branch 'main' into feat/public-api 2024-02-09 16:00:40 +11:00
Lucas Smith
748bf6de6b fix: add dropped constants from merge 2024-02-08 22:12:04 +11:00
Lucas Smith
d13cf743bf Merge branch 'main' into feat/add-runtime-env 2024-02-08 22:06:59 +11:00
Mythie
98df273ebc feat: add field and recipient endpoints 2024-02-08 16:58:44 +11:00
Catalin Pit
b3514bd0c7 add new webhook dialog 2024-02-07 16:04:12 +02:00
Catalin Pit
edeeaa5651 feat: implement webhooks 2024-02-06 16:12:31 +02:00
apoorv taneja
c970abc871 added onchange handler 2024-02-02 20:46:54 +05:30
apoorv taneja
d5b3df1648 fixed variable declaration 2024-02-02 19:32:39 +05:30
apoorv taneja
142c1c003e changed useEffect variables 2024-02-02 18:16:54 +05:30
apoorv taneja
a06c628653 Merge branch 'main' of https://github.com/plxity/documenso into fix/undo-button-in-canvas 2024-02-02 17:56:58 +05:30
apoorv taneja
7ca3697303 Merge branch 'main' of https://github.com/plxity/documenso into fix/undo-button-in-canvas 2024-02-02 14:49:06 +05:30
Lucas Smith
8ac2209493 Merge branch 'main' into chore-security-text 2024-02-02 16:16:25 +11:00
Lucas Smith
9c4ec34a3c fix: add precommit step for .well-known 2024-02-02 04:00:28 +00:00
Timur Ercan
1f142e334a Merge branch 'main' into chore-security-text 2024-01-31 20:31:34 +01:00
Mythie
08f82b23dc fix: update env entries to evaluate at runtime 2024-01-31 22:32:42 +11:00
Timur Ercan
747a7b0aea chore: security contacts and descr 2024-01-30 16:15:32 +01:00
Timur Ercan
375df71f5c Merge branch 'main' into chore-security-text 2024-01-29 16:43:57 +01:00
Catalin Pit
c0bb5205e1 chore: merged main 2024-01-29 10:14:56 +02:00
Tangerine Kugelmann
927a656c57 Create security.txt
See also https://securitytxt.org
2024-01-28 01:00:07 +01:00
Catalin Pit
751fb5275c Merge branch 'main' into feat/add-runtime-env 2024-01-26 14:04:54 +02:00
Catalin Pit
2f18518961 chore: merged main 2024-01-25 10:53:05 +02:00
Catalin Pit
d451a7acce feat: add next-runtime-env 2024-01-25 10:48:20 +02:00
apoorv taneja
d8aecc4092 fixed undo operation on signature pad 2024-01-25 13:21:55 +05:30
Sumit Bisht
e5c2263e92 fix: imporoved document-dropzone ui for small vertical screens 2024-01-23 18:37:02 +05:30
Mythie
5a28eaa4ff feat: add recipient creation 2024-01-22 17:38:02 +11:00
Catalin Pit
b6aface982 chore: update api description 2024-01-19 16:59:48 +02:00
Catalin Pit
b28a7f9702 chore: add openapi 2024-01-19 16:55:16 +02:00
Catalin Pit
3b82ba57f3 chore: implemented feedback plus some restructuring 2024-01-17 12:44:25 +02:00
Mythie
a1215df91a refactor: extract api implementation to package
Extracts the API implementation to a package so we can
potentially reuse it across different applications in the
event that we move off using a Next.js API route.

Additionally tidies up the tokens page and form to be more simplified.
2023-12-31 13:58:15 +11:00
Catalin Pit
d283cc2d26 chore: implemented feedback 2023-12-21 16:02:02 +02:00
Catalin Pit
6a56905fea chore: merged main 2023-12-21 10:14:07 +02:00
Catalin Pit
a22ada5f41 chore: add delete cascade 2023-12-20 14:44:43 +02:00
Catalin Pit
fb46b09e4f chore: small changes 2023-12-20 12:47:46 +02:00
Catalin Pit
17486b961d chore: refactor delete dialog 2023-12-19 15:51:43 +02:00
Catalin Pit
da03fc1fd0 chore: finishing touches 2023-12-18 12:24:42 +02:00
Catalin Pit
19736ce60b chore: implemented feedback 2023-12-14 11:05:39 +02:00
Catalin Pit
e79d385534 Merge branch 'main' into feat/public-api 2023-12-11 14:44:29 +02:00
Catalin Pit
8ecd8a7d10 chore: implemented feedback + a small refactoring 2023-12-11 14:33:30 +02:00
Catalin Pit
66c0db91da chore: cleanup and feedback implementation 2023-12-08 13:28:34 +00:00
Catalin Pit
54401b94ae chore: split api contract
moved the schemas from the api contract to a separate file
2023-12-08 09:58:23 +00:00
Catalin Pit
11ae6d3c16 chore: small changes 2023-12-06 16:53:34 +00:00
Catalin Pit
6c5526dd49 chore: update routes
trying to add the route for creating documents
2023-12-06 15:27:30 +00:00
Catalin Pit
936e75fd30 chore: merged main 2023-12-06 13:18:59 +00:00
Catalin Pit
6be4b7ae90 feat: add authorization for api calls 2023-11-30 14:39:31 +02:00
Catalin Pit
76800674ee feat: improve messaging 2023-11-29 14:57:27 +02:00
Catalin Pit
d43d40fd6b feat: improvements to the newly created token message 2023-11-29 14:43:26 +02:00
Catalin Pit
e1732de81d feat: show newly created token 2023-11-28 15:49:46 +02:00
Catalin Pit
6a5fc7a5fb feat: confirm to delete dialog 2023-11-28 12:37:01 +02:00
Catalin Pit
13997d3dca feat: add delete and copy token on token page 2023-11-27 16:29:24 +02:00
Catalin Pit
2deaad5c34 feat: token page 2023-11-27 12:50:21 +02:00
Catalin Pit
fbee6eedc1 feat: api token functions 2023-11-24 16:13:09 +02:00
Catalin Pit
80fe7ccdf5 feat: api token page in the settings 2023-11-24 13:59:33 +02:00
Catalin Pit
2ccede72ea chore: update the contract to add deleteDocument route 2023-11-23 15:23:47 +02:00
Catalin Pit
309b56168a feat: create the model for the api token 2023-11-23 15:21:13 +02:00
Catalin Pit
5c8a77ee8f chore: merged main 2023-11-23 12:05:28 +02:00
Catalin Pit
b3008fb272 feat: add route for retrieving a single document by id 2023-11-23 10:02:22 +02:00
Catalin Pit
6d6c93539f feat: update contract 2023-11-22 15:51:04 +02:00
Catalin Pit
4a6b3edc05 feat: get documents api route with pagination 2023-11-22 15:44:49 +02:00
Catalin Pit
24d9906557 feat: public api start 2023-11-22 15:03:15 +02:00
182 changed files with 6769 additions and 56957 deletions

View File

@@ -1,4 +1,16 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh" . "$(dirname -- "$0")/_/husky.sh"
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
echo "Copying pdf.js"
npm run copy:pdfjs --workspace apps/**
echo "Copying .well-known/ contents"
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
git add "$MONOREPO_ROOT/apps/web/public/"
git add "$MONOREPO_ROOT/apps/marketing/public/"
npx lint-staged npx lint-staged

7
.well-known/security.txt Normal file
View File

@@ -0,0 +1,7 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt

View File

@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'
date: 2024-01-25 date: 2024-01-25
Tags: tags:
- Vision - Vision
- Mission - Mission
- Open Source - Open Source

View File

@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'
date: 2024-01-10 date: 2024-01-10
Tags: tags:
- GitHub - GitHub
- Backlog - Backlog
- Roadmap - Roadmap

View File

@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'
date: 2024-02-06 date: 2024-02-06
Tags: tags:
- Founders - Founders
- Mission - Mission
- Open Source - Open Source

View File

@@ -0,0 +1,7 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt

File diff suppressed because one or more lines are too long

View File

@@ -5,14 +5,13 @@ import { allDocuments } from 'contentlayer/generated';
import type { MDXComponents } from 'mdx/types'; import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks'; import { useMDXComponent } from 'next-contentlayer/hooks';
export const generateStaticParams = () => export const dynamic = 'force-dynamic';
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
export const generateMetadata = ({ params }: { params: { content: string } }) => { export const generateMetadata = ({ params }: { params: { content: string } }) => {
const document = allDocuments.find((post) => post._raw.flattenedPath === params.content); const document = allDocuments.find((doc) => doc._raw.flattenedPath === params.content);
if (!document) { if (!document) {
notFound(); return { title: 'Not Found' };
} }
return { title: document.title }; return { title: document.title };

View File

@@ -7,14 +7,15 @@ import { ChevronLeft } from 'lucide-react';
import type { MDXComponents } from 'mdx/types'; import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks'; import { useMDXComponent } from 'next-contentlayer/hooks';
export const generateStaticParams = () => export const dynamic = 'force-dynamic';
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
export const generateMetadata = ({ params }: { params: { post: string } }) => { export const generateMetadata = ({ params }: { params: { post: string } }) => {
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`); const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
if (!blogPost) { if (!blogPost) {
notFound(); return {
title: 'Not Found',
};
} }
return { return {

View File

@@ -5,6 +5,7 @@ import { allBlogPosts } from 'contentlayer/generated';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Blog', title: 'Blog',
}; };
export default function BlogPage() { export default function BlogPage() {
const blogPosts = allBlogPosts.sort((a, b) => { const blogPosts = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.date); const dateA = new Date(a.date);

View File

@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@@ -12,6 +13,8 @@ import { Button } from '@documenso/ui/primitives/button';
import { PasswordReveal } from '~/components/(marketing)/password-reveal'; import { PasswordReveal } from '~/components/(marketing)/password-reveal';
export const dynamic = 'force-dynamic';
const fontCaveat = Caveat({ const fontCaveat = Caveat({
weight: ['500'], weight: ['500'],
subsets: ['latin'], subsets: ['latin'],
@@ -175,11 +178,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
This is a temporary password. Please change it as soon as possible. This is a temporary password. Please change it as soon as possible.
</p> </p>
<Link <Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signin`} target="_blank" className="mt-4 block">
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`}
target="_blank"
className="mt-4 block"
>
<Button size="lg" className="text-base"> <Button size="lg" className="text-base">
Let's get started! Let's get started!
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5" />

View File

@@ -147,7 +147,12 @@ export default async function OpenPage() {
<p className="text-muted-foreground mt-4 max-w-[60ch] 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 All our metrics, finances, and learnings are public. We believe in transparency and want
to share our journey with you. You can read more about why here:{' '} 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"> <a
className="font-bold"
href="https://documenso.com/blog/pre-seed"
target="_blank"
rel="noreferrer"
>
Announcing Open Metrics Announcing Open Metrics
</a> </a>
</p> </p>

View File

@@ -15,6 +15,8 @@ export const metadata: Metadata = {
title: 'Pricing', title: 'Pricing',
}; };
export const dynamic = 'force-dynamic';
export type PricingPageProps = { export type PricingPageProps = {
searchParams?: { searchParams?: {
planId?: string; planId?: string;
@@ -53,7 +55,7 @@ export default function PricingPage() {
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<Button variant="outline" size="lg" className="rounded-full hover:cursor-pointer" asChild> <Button variant="outline" size="lg" className="rounded-full hover:cursor-pointer" asChild>
<Link href="https://github.com/documenso/documenso" target="_blank"> <Link href="https://github.com/documenso/documenso" target="_blank" rel="noreferrer">
Get Started Get Started
</Link> </Link>
</Button> </Button>
@@ -166,6 +168,7 @@ export default function PricingPage() {
<Link <Link
className="text-documenso-700 font-bold" className="text-documenso-700 font-bold"
target="_blank" target="_blank"
rel="noreferrer"
href="mailto:support@documenso.com" href="mailto:support@documenso.com"
> >
support@documenso.com support@documenso.com
@@ -175,6 +178,7 @@ export default function PricingPage() {
className="text-documenso-700 font-bold" className="text-documenso-700 font-bold"
href="https://documen.so/discord" href="https://documen.so/discord"
target="_blank" target="_blank"
rel="noreferrer"
> >
in our Discord-Support-Channel in our Discord-Support-Channel
</a>{' '} </a>{' '}

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
@@ -190,7 +191,7 @@ export const SinglePlayerClient = () => {
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal"> <p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '} Create a{' '}
<Link <Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`} href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank" target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors" className="hover:text-foreground/80 font-semibold transition-colors"
> >

View File

@@ -7,6 +7,7 @@ export const metadata: Metadata = {
}; };
export const revalidate = 0; export const revalidate = 0;
export const dynamic = 'force-dynamic';
// !: This entire file is a hack to get around failed prerendering of // !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during // !: the Single Player Mode page. This regression was introduced during

View File

@@ -3,6 +3,7 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google'; import { Caveat, Inter } from 'next/font/google';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag'; import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
import { TrpcProvider } from '@documenso/trpc/react'; import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -17,7 +18,8 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = { export function generateMetadata() {
return {
title: { title: {
template: '%s - Documenso', template: '%s - Documenso',
default: 'Documenso', default: 'Documenso',
@@ -28,21 +30,23 @@ export const metadata = {
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates', 'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' }, authors: { name: 'Documenso, Inc.' },
robots: 'index, follow', robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3000'),
openGraph: { openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative', title: 'Documenso - The Open Source DocuSign Alternative',
description: 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.', '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', type: 'website',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`], images: ['/opengraph-image.jpg'],
}, },
twitter: { twitter: {
site: '@documenso', site: '@documenso',
card: 'summary_large_image', card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`], images: ['/opengraph-image.jpg'],
description: 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.', '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 async function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getAllAnonymousFlags(); const flags = await getAllAnonymousFlags();

View File

@@ -8,6 +8,7 @@ import Link from 'next/link';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible'; import { usePlausible } from 'next-plausible';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -82,11 +83,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p> </p>
<Button className="rounded-full text-base" asChild> <Button className="rounded-full text-base" asChild>
<Link <Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6">
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
target="_blank"
className="mt-6"
>
Signup Now Signup Now
</Link> </Link>
</Button> </Button>
@@ -117,13 +114,13 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p> </p>
<Button className="mt-6 rounded-full text-base" asChild> <Button className="mt-6 rounded-full text-base" asChild>
<Link href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}>Signup Now</Link> <Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}>Signup Now</Link>
</Button> </Button>
<div className="mt-8 flex w-full flex-col divide-y"> <div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4 font-medium"> <p className="text-foreground py-4 font-medium">
{' '} {' '}
<a href="https://documenso.com/blog/early-adopters" target="_blank"> <a href="https://documenso.com/blog/early-adopters" target="_blank" rel="noreferrer">
The Early Adopter Deal: The Early Adopter Deal:
</a> </a>
</p> </p>
@@ -133,7 +130,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
<p className="text-foreground py-4"> <p className="text-foreground py-4">
<strong> <strong>
{' '} {' '}
<a href="https://documenso.com/blog/early-adopters" target="_blank"> <a
href="https://documenso.com/blog/early-adopters"
target="_blank"
rel="noreferrer"
>
Includes all upcoming features Includes all upcoming features
</a> </a>
</strong> </strong>

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { Signature } from '@documenso/prisma/client'; import type { Signature } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
@@ -85,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
<p className="text-muted-foreground/60 mt-16 text-center text-sm"> <p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '} Create a{' '}
<Link <Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`} href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank" target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap" className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
> >

View File

@@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { usePlausible } from 'next-plausible'; import { usePlausible } from 'next-plausible';
import { env } from 'next-runtime-env';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -144,7 +145,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
setTimeout(resolve, 1000); setTimeout(resolve, 1000);
}); });
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; const planId = env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID');
if (!planId) {
throw new Error('No plan ID found.');
}
const claimPlanInput = signatureDataUrl const claimPlanInput = signatureDataUrl
? { ? {

View File

@@ -1,13 +1,15 @@
import { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata'; import type { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TClaimPlanResponseSchema, ZClaimPlanRequestSchema } from '~/api/claim-plan/types'; import type { TClaimPlanResponseSchema } from '~/api/claim-plan/types';
import { ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@@ -40,7 +42,7 @@ export default async function handler(
if (user) { if (user) {
return res.status(200).json({ return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`, redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/signin`,
}); });
} }
@@ -77,8 +79,8 @@ export default async function handler(
mode: 'subscription', mode: 'subscription',
metadata, metadata,
allow_promotion_codes: true, allow_promotion_codes: true,
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`, success_url: `${NEXT_PUBLIC_MARKETING_URL()}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}`, cancel_url: `${NEXT_PUBLIC_MARKETING_URL()}`,
}); });
if (!checkout.url) { if (!checkout.url) {

View File

@@ -14,6 +14,7 @@
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
}, },
"dependencies": { "dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*", "@documenso/assets": "*",
"@documenso/ee": "*", "@documenso/ee": "*",
"@documenso/lib": "*", "@documenso/lib": "*",

View File

@@ -0,0 +1,7 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt

View File

@@ -108,7 +108,6 @@ export const ResendDocumentActionItem = ({
}; };
return ( return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}> <DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
@@ -190,6 +189,5 @@ export const ResendDocumentActionItem = ({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
); );
}; };

View File

@@ -117,7 +117,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
<DocumentDropzone <DocumentDropzone
className="min-h-[40vh]" className="h-[min(400px,50vh)]"
disabled={remaining.documents === 0 || !session?.user.emailVerified} disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage} disabledMessage={disabledMessage}
onDrop={onFileDrop} onDrop={onFileDrop}

View File

@@ -2,6 +2,7 @@
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
export const createBillingPortal = async () => { export const createBillingPortal = async () => {
@@ -11,6 +12,6 @@ export const createBillingPortal = async () => {
return getPortalSession({ return getPortalSession({
customerId: stripeCustomer.id, customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
}); });
}; };

View File

@@ -3,6 +3,7 @@
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session'; import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id'; import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
@@ -27,13 +28,13 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
if (foundSubscription) { if (foundSubscription) {
return getPortalSession({ return getPortalSession({
customerId: stripeCustomer.id, customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
}); });
} }
return getCheckoutSession({ return getCheckoutSession({
customerId: stripeCustomer.id, customerId: stripeCustomer.id,
priceId, priceId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
}); });
}; };

View File

@@ -5,7 +5,7 @@ import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan'; import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id'; import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
@@ -37,23 +37,23 @@ export default async function BillingSettingsPage() {
user = await getStripeCustomerByUser(user).then((result) => result.user); user = await getStripeCustomerByUser(user).then((result) => result.user);
} }
const [subscriptions, prices, communityPlanPrices] = await Promise.all([ const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }), getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }), getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY), getPrimaryAccountPlanPrices(),
]); ]);
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id); const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null; let subscriptionProduct: Stripe.Product | null = null;
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) => const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
communityPlanPriceIds.includes(priceId), primaryAccountPlanPriceIds.includes(priceId),
); );
const subscription = const subscription =
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
communityPlanUserSubscriptions[0]; primaryAccountPlanSubscriptions[0];
if (subscription?.priceId) { if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(

View File

@@ -0,0 +1,74 @@
import { DateTime } from 'luxon';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
import { Button } from '@documenso/ui/primitives/button';
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
import { LocaleDate } from '~/components/formatter/locale-date';
import { ApiTokenForm } from '~/components/forms/token';
export default async function ApiTokensPage() {
const { user } = await getRequiredServerComponentSession();
const tokens = await getUserTokens({ userId: user.id });
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
On this page, you can create new API tokens and manage the existing ones.
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">Your existing tokens</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
Your tokens will be shown here once you create them.
</p>
</div>
)}
{tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Token doesn't have an expiration date
</p>
)}
</div>
<div>
<DeleteTokenDialog token={token}>
<Button variant="destructive">Delete</Button>
</DeleteTokenDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,169 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { MultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/multiselect-combobox';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export type WebhookPageOptions = {
params: {
id: number;
};
};
export default function WebhookPage({ params }: WebhookPageOptions) {
const { toast } = useToast();
const router = useRouter();
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
{
id: Number(params.id),
},
{ enabled: !!params.id },
);
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
const form = useForm<TEditWebhookFormSchema>({
resolver: zodResolver(ZEditWebhookFormSchema),
values: {
webhookUrl: webhook?.webhookUrl ?? '',
eventTriggers: webhook?.eventTriggers ?? [],
secret: webhook?.secret ?? '',
enabled: webhook?.enabled ?? true,
},
});
const onSubmit = async (data: TEditWebhookFormSchema) => {
try {
await updateWebhook({
id: Number(params.id),
...data,
});
toast({
title: 'Webhook updated',
description: 'The webhook has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
toast({
title: 'Failed to update webhook',
description: 'We encountered an error while updating the webhook. Please try again later.',
variant: 'destructive',
});
}
};
return (
<div>
<SettingsHeader
title="Edit webhook"
subtitle="On this page, you can edit the webhook and its settings."
/>
{isLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="webhookUrl">Webhook URL</FormLabel>
<Input {...field} id="webhookUrl" type="text" />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col">
<FormLabel required>Event triggers</FormLabel>
<FormControl>
<MultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2">
<FormLabel className="mt-2">Active</FormLabel>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update webhook
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import Link from 'next/link';
import { Zap } from 'lucide-react';
import { ToggleLeft, ToggleRight } from 'lucide-react';
import { Loader } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
export default function WebhookPage() {
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
return (
<div>
<SettingsHeader
title="Webhooks"
subtitle="On this page, you can create new Webhooks and manage the existing ones."
>
<CreateWebhookDialog />
</SettingsHeader>
{isLoading && (
<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>
)}
{webhooks && webhooks.length === 0 && (
// TODO: Perhaps add some illustrations here to make the page more engaging
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
You have no webhooks yet. Your webhooks will be shown here once you create them.
</p>
</div>
)}
{webhooks && webhooks.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{webhooks?.map((webhook) => (
<div key={webhook.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h4 className="text-lg font-semibold">Webhook URL</h4>
<p className="text-muted-foreground">{webhook.webhookUrl}</p>
<h4 className="mt-4 text-lg font-semibold">Event triggers</h4>
{webhook.eventTriggers.map((trigger, index) => (
<span key={index} className="text-muted-foreground flex flex-row items-center">
<Zap className="mr-1 h-4 w-4" /> {trigger}
</span>
))}
{webhook.enabled ? (
<h4 className="mt-4 flex items-center gap-2 text-lg">
Active <ToggleRight className="h-6 w-6 fill-green-200 stroke-green-400" />
</h4>
) : (
<h4 className="mt-4 flex items-center gap-2 text-lg">
Inactive <ToggleLeft className="h-6 w-6 fill-slate-200 stroke-slate-400" />
</h4>
)}
</div>
</div>
<div className="mt-6 flex flex-col-reverse space-y-2 space-y-reverse sm:mt-0 sm:flex-row sm:justify-end sm:space-x-2 sm:space-y-0">
<Button asChild variant="outline">
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
</Button>
<DeleteWebhookDialog webhook={webhook}>
<Button variant="destructive">Delete</Button>
</DeleteWebhookDialog>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { ShareHandlerAPIResponse } from '~/pages/api/share'; import type { ShareHandlerAPIResponse } from '~/pages/api/share';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -37,7 +39,7 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
), ),
]); ]);
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const recipientOrSender: ShareHandlerAPIResponse = await fetch( const recipientOrSender: ShareHandlerAPIResponse = await fetch(
new URL(`/api/share?slug=${slug}`, baseUrl), new URL(`/api/share?slug=${slug}`, baseUrl),

View File

@@ -1,8 +1,8 @@
import { Metadata } from 'next'; import type { Metadata } from 'next';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { APP_BASE_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
type SharePageProps = { type SharePageProps = {
params: { slug: string }; params: { slug: string };
@@ -16,12 +16,12 @@ export function generateMetadata({ params: { slug } }: SharePageProps) {
title: 'Documenso - Join the open source signing revolution', title: 'Documenso - Join the open source signing revolution',
description: 'I just signed with Documenso!', description: 'I just signed with Documenso!',
type: 'website', type: 'website',
images: [`${APP_BASE_URL}/share/${slug}/opengraph`], images: [`/share/${slug}/opengraph`],
}, },
twitter: { twitter: {
site: '@documenso', site: '@documenso',
card: 'summary_large_image', card: 'summary_large_image',
images: [`${APP_BASE_URL}/share/${slug}/opengraph`], images: [`/share/${slug}/opengraph`],
description: 'I just signed with Documenso!', description: 'I just signed with Documenso!',
}, },
} satisfies Metadata; } satisfies Metadata;
@@ -35,5 +35,5 @@ export default function SharePage() {
return null; return null;
} }
redirect(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001'); redirect(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001');
} }

View File

@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
@@ -18,6 +20,8 @@ type SignInPageProps = {
}; };
export default function SignInPage({ searchParams }: SignInPageProps) { export default function SignInPage({ searchParams }: SignInPageProps) {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined; const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null; const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
@@ -39,7 +43,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/> />
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && ( {NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm"> <p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '} Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70"> <Link href="/signup" className="text-primary duration-200 hover:opacity-70">

View File

@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
@@ -18,7 +20,9 @@ type SignUpPageProps = {
}; };
export default function SignUpPage({ searchParams }: SignUpPageProps) { export default function SignUpPage({ searchParams }: SignUpPageProps) {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin'); redirect('/signin');
} }

View File

@@ -0,0 +1,3 @@
'use client';
export { OpenApiDocsPage as default } from '@documenso/api/v1/api-documentation';

View File

@@ -2,8 +2,11 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google'; import { Caveat, Inter } from 'next/font/google';
import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale'; import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getLocale } from '@documenso/lib/server-only/headers/get-locale'; import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { TrpcProvider } from '@documenso/trpc/react'; import { TrpcProvider } from '@documenso/trpc/react';
@@ -19,7 +22,8 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = { export function generateMetadata() {
return {
title: { title: {
template: '%s - Documenso', template: '%s - Documenso',
default: 'Documenso', default: 'Documenso',
@@ -30,21 +34,23 @@ export const metadata = {
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates', 'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' }, authors: { name: 'Documenso, Inc.' },
robots: 'index, follow', robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
openGraph: { openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative', title: 'Documenso - The Open Source DocuSign Alternative',
description: 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.', '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', type: 'website',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`], images: ['/opengraph-image.jpg'],
}, },
twitter: { twitter: {
site: '@documenso', site: '@documenso',
card: 'summary_large_image', card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`], images: ['/opengraph-image.jpg'],
description: 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.', '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 async function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags(); const flags = await getServerComponentAllFlags();
@@ -62,6 +68,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.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="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<PublicEnvScript />
</head> </head>
<Suspense> <Suspense>

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
@@ -25,7 +26,7 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
return; return;
} }
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => { void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.', description: 'The signing link has been copied to your clipboard.',

View File

@@ -3,6 +3,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { import {
Braces,
CreditCard, CreditCard,
FileSpreadsheet, FileSpreadsheet,
Lock, Lock,
@@ -98,6 +99,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings/tokens" className="cursor-pointer">
<Braces className="mr-2 h-4 w-4" />
API Tokens
</Link>
</DropdownMenuItem>
{isBillingEnabled && ( {isBillingEnabled && (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="/settings/billing" className="cursor-pointer"> <Link href="/settings/billing" className="cursor-pointer">

View File

@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CreditCard, Lock, User, Users } from 'lucide-react'; import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -51,6 +51,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Link> </Link>
)} )}
<Link href="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
<Link href="/settings/security"> <Link href="/settings/security">
<Button <Button
variant="ghost" variant="ghost"
@@ -64,6 +77,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button> </Button>
</Link> </Link>
<Link href="/settings/tokens">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
)}
>
<Braces className="mr-2 h-5 w-5" />
API Tokens
</Button>
</Link>
{isBillingEnabled && ( {isBillingEnabled && (
<Link href="/settings/billing"> <Link href="/settings/billing">
<Button <Button

View File

@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CreditCard, Lock, User, Users } from 'lucide-react'; import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -54,6 +54,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Link> </Link>
)} )}
<Link href="/settings/webhooks">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
<Link href="/settings/security"> <Link href="/settings/security">
<Button <Button
variant="ghost" variant="ghost"
@@ -67,6 +80,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button> </Button>
</Link> </Link>
<Link href="/settings/tokens">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
)}
>
<Braces className="mr-2 h-5 w-5" />
API Tokens
</Button>
</Link>
{isBillingEnabled && ( {isBillingEnabled && (
<Link href="/settings/billing"> <Link href="/settings/billing">
<Button <Button

View File

@@ -0,0 +1,7 @@
export const EXPIRATION_DATES = {
ONE_WEEK: '7 days',
ONE_MONTH: '1 month',
THREE_MONTHS: '3 months',
SIX_MONTHS: '6 months',
ONE_YEAR: '12 months',
} as const;

View File

@@ -0,0 +1,178 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { ApiToken } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTokenDialogProps = {
token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void;
children?: React.ReactNode;
};
export default function DeleteTokenDialog({ token, onDelete, children }: DeleteTokenDialogProps) {
const router = useRouter();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const deleteMessage = `delete ${token.name}`;
const ZDeleteTokenDialogSchema = z.object({
tokenName: z.literal(deleteMessage, {
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
}),
});
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenDialogSchema>;
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
onSuccess() {
onDelete?.();
},
});
const form = useForm<TDeleteTokenByIdMutationSchema>({
resolver: zodResolver(ZDeleteTokenDialogSchema),
values: {
tokenName: '',
},
});
const onSubmit = async () => {
try {
await deleteTokenMutation({
id: token.id,
});
toast({
title: 'Token deleted',
description: 'The token was deleted successfully.',
duration: 5000,
});
setIsOpen(false);
router.refresh();
} catch (error) {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
duration: 5000,
description:
'We encountered an unknown error while attempting to delete this token. Please try again later.',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
return (
<Dialog
open={isOpen}
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
>
<DialogTrigger asChild={true}>
{children ?? (
<Button className="mr-4" variant="destructive">
Delete
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure you want to delete this token?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your token will be
permanently deleted.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem>
<FormLabel>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</FormLabel>
<FormControl>
<Input className="bg-background" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
I'm sure! Delete it
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZCreateWebhookFormSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { MultiSelectCombobox } from './multiselect-combobox';
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
export type CreateWebhookDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const form = useForm<TCreateWebhookFormSchema>({
resolver: zodResolver(ZCreateWebhookFormSchema),
values: {
webhookUrl: '',
eventTriggers: [],
secret: '',
enabled: true,
},
});
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
const onSubmit = async (values: TCreateWebhookFormSchema) => {
try {
await createWebhook(values);
setOpen(false);
toast({
title: 'Webhook created',
description: 'The webhook was successfully created.',
});
form.reset();
router.refresh();
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating the webhook. Please try again.',
variant: 'destructive',
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
{...props}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? <Button className="flex-shrink-0">Create Webhook</Button>}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Create webhook</DialogTitle>
<DialogDescription>On this page, you can create a new webhook.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>Event triggers</FormLabel>
<FormControl>
<MultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput
className="bg-background"
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormLabel className="mt-2">Active</FormLabel>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
Create
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,167 @@
'use effect';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { Webhook } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteWebhookDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
onDelete?: () => void;
children: React.ReactNode;
};
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const deleteMessage = `delete ${webhook.webhookUrl}`;
const ZDeleteWebhookFormSchema = z.object({
webhookUrl: z.literal(deleteMessage, {
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
}),
});
type TDeleteWebhookFormSchema = z.infer<typeof ZDeleteWebhookFormSchema>;
const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation();
const form = useForm<TDeleteWebhookFormSchema>({
resolver: zodResolver(ZDeleteWebhookFormSchema),
values: {
webhookUrl: '',
},
});
const onSubmit = async () => {
try {
await deleteWebhook({ id: webhook.id });
toast({
title: 'Webhook deleted',
duration: 5000,
description: 'The webhook has been successfully deleted.',
});
setOpen(false);
router.refresh();
} catch (error) {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
duration: 5000,
description:
'We encountered an unknown error while attempting to delete it. Please try again later.',
});
}
};
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{children ?? (
<Button className="mr-4" variant="destructive">
Delete
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Webhook</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your webhook will be
permanently deleted.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</FormLabel>
<FormControl>
<Input className="bg-background" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
I'm sure! Delete it
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,85 @@
import * as React from 'react';
import { WebhookTriggerEvents } from '@prisma/client/';
import { Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { truncateTitle } from '~/helpers/truncate-title';
type ComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
const [isOpen, setIsOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
const triggerEvents = Object.values(WebhookTriggerEvents);
React.useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setIsOpen(false);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isOpen}
className="w-[200px] justify-between"
>
{selectedValues.length > 0 ? selectedValues.length + ' selected...' : 'Select values...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="z-9999 w-[200px] p-0">
<Command>
<CommandInput placeholder={truncateTitle(selectedValues.join(', '), 15)} />
<CommandEmpty>No value found.</CommandEmpty>
<CommandGroup>
{allEvents.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{value}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};
export { MultiSelectCombobox };

View File

@@ -238,7 +238,7 @@ export const TransferTeamDialog = ({
<Alert variant="neutral"> <Alert variant="neutral">
<AlertDescription> <AlertDescription>
<ul className="list-outside list-disc space-y-2 pl-4"> <ul className="list-outside list-disc space-y-2 pl-4">
{IS_BILLING_ENABLED && ( {IS_BILLING_ENABLED() && (
// Temporary removed. // Temporary removed.
// <li> // <li>
// {form.getValues('clearPaymentMethods') // {form.getValues('clearPaymentMethods')

View File

@@ -48,7 +48,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button> </Button>
</Link> </Link>
{IS_BILLING_ENABLED && ( {IS_BILLING_ENABLED() && (
<Link href={billingPath}> <Link href={billingPath}>
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -56,7 +56,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button> </Button>
</Link> </Link>
{IS_BILLING_ENABLED && ( {IS_BILLING_ENABLED() && (
<Link href={billingPath}> <Link href={billingPath}>
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -67,7 +67,7 @@ export const TeamsMemberPageDataTable = ({
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto"> <Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
<TabsList> <TabsList>
<TabsTrigger className="min-w-[60px]" value="members" asChild> <TabsTrigger className="min-w-[60px]" value="members" asChild>
<Link href={pathname ?? '/'}>All</Link> <Link href={pathname ?? '/'}>Active</Link>
</TabsTrigger> </TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="invites" asChild> <TabsTrigger className="min-w-[60px]" value="invites" asChild>

View File

@@ -1,14 +1,12 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { renderSVG } from 'uqr'; import { renderSVG } from 'uqr';
import { z } from 'zod'; import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -54,14 +52,16 @@ export const EnableAuthenticatorAppDialog = ({
open, open,
onOpenChange, onOpenChange,
}: EnableAuthenticatorAppDialogProps) => { }: EnableAuthenticatorAppDialogProps) => {
const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } = const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
trpc.twoFactorAuthentication.setup.useMutation(); trpc.twoFactorAuthentication.setup.useMutation();
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } = const {
trpc.twoFactorAuthentication.enable.useMutation(); mutateAsync: enableTwoFactorAuthentication,
data: enableTwoFactorAuthenticationData,
isLoading: isEnableTwoFactorAuthenticationDataLoading,
} = trpc.twoFactorAuthentication.enable.useMutation();
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({ const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
defaultValues: { defaultValues: {
@@ -115,6 +115,19 @@ export const EnableAuthenticatorAppDialog = ({
} }
}; };
const downloadRecoveryCodes = () => {
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
type: 'text/plain',
});
downloadFile({
filename: 'documenso-2FA-recovery-codes.txt',
data: blob,
});
}
};
const onEnableTwoFactorAuthenticationFormSubmit = async ({ const onEnableTwoFactorAuthenticationFormSubmit = async ({
token, token,
}: TEnableTwoFactorAuthenticationForm) => { }: TEnableTwoFactorAuthenticationForm) => {
@@ -136,14 +149,6 @@ export const EnableAuthenticatorAppDialog = ({
} }
}; };
const onCompleteClick = () => {
flushSync(() => {
onOpenChange(false);
});
router.refresh();
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl"> <DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
@@ -270,9 +275,16 @@ export const EnableAuthenticatorAppDialog = ({
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} /> <RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
)} )}
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between"> <div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button type="button" onClick={() => onCompleteClick()}> <Button onClick={() => onOpenChange(false)}>Complete</Button>
Complete
<Button
variant="secondary"
onClick={downloadRecoveryCodes}
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
loading={isEnableTwoFactorAuthenticationDataLoading}
>
Download
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -42,8 +43,11 @@ export type ViewRecoveryCodesDialogProps = {
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => { export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } = const {
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); mutateAsync: viewRecoveryCodes,
data: viewRecoveryCodesData,
isLoading: isViewRecoveryCodesDataLoading,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({ const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: { defaultValues: {
@@ -62,6 +66,19 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
return 'view'; return 'view';
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]); }, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
const downloadRecoveryCodes = () => {
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
type: 'text/plain',
});
downloadFile({
filename: 'documenso-2FA-recovery-codes.txt',
data: blob,
});
}
};
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => { const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
try { try {
await viewRecoveryCodes({ password }); await viewRecoveryCodes({ password });
@@ -139,8 +156,17 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} /> <RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
)} )}
<div className="mt-4 flex flex-row-reverse items-center justify-between"> <div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button onClick={() => onOpenChange(false)}>Complete</Button> <Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
disabled={!viewRecoveryCodesData?.recoveryCodes}
loading={isViewRecoveryCodesDataLoading}
onClick={downloadRecoveryCodes}
>
Download
</Button>
</div> </div>
</div> </div>
)) ))

View File

@@ -0,0 +1,255 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EXPIRATION_DATES } from '../(dashboard)/settings/token/contants';
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({
enabled: z.boolean(),
});
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
export type ApiTokenFormProps = {
className?: string;
};
export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
const router = useRouter();
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
const [newlyCreatedToken, setNewlyCreatedToken] = useState('');
const [noExpirationDate, setNoExpirationDate] = useState(false);
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
onSuccess(data) {
setNewlyCreatedToken(data.token);
},
});
const form = useForm<TCreateTokenFormSchema>({
resolver: zodResolver(ZCreateTokenFormSchema),
defaultValues: {
tokenName: '',
expirationDate: '',
enabled: false,
},
});
const copyToken = async (token: string) => {
try {
const copied = await copy(token);
if (!copied) {
throw new Error('Unable to copy the token');
}
toast({
title: 'Token copied to clipboard',
description: 'The token was copied to your clipboard.',
});
} catch (error) {
toast({
title: 'Unable to copy token',
description: 'We were unable to copy the token to your clipboard. Please try again.',
variant: 'destructive',
});
}
};
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
try {
await createTokenMutation({
tokenName,
expirationDate: noExpirationDate ? null : expirationDate,
});
toast({
title: 'Token created',
description: 'A new token was created successfully.',
duration: 5000,
});
form.reset();
router.refresh();
} catch (error) {
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: error.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
duration: 5000,
description:
'We encountered an unknown error while attempting create the new token. Please try again later.',
});
}
}
};
return (
<div className={cn(className)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-4">
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">Token name</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Input type="text" {...field} />
</FormControl>
</div>
<FormDescription className="text-xs italic">
Please enter a meaningful name for your token. This will help you identify it
later.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">Token expiration date</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose..." />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{date}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="">
<FormLabel className="text-muted-foreground mt-2">Never expire</FormLabel>
<FormControl>
<div className="block md:py-1.5">
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={(val) => {
setNoExpirationDate((prev) => !prev);
field.onChange(val);
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
className="hidden md:inline-flex"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
Create token
</Button>
<div className="md:hidden">
<Button
type="submit"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
Create token
</Button>
</div>
</fieldset>
</form>
</Form>
{newlyCreatedToken && (
<Card className="mt-8" gradient>
<CardContent className="p-4">
<p className="text-muted-foreground mt-2 text-sm">
Your token was created successfully! Make sure to copy it because you won't be able to
see it again!
</p>
<p className="bg-muted-foreground/10 my-4 rounded-md px-2.5 py-1 font-mono text-sm">
{newlyCreatedToken}
</p>
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken)}>
Copy token
</Button>
</CardContent>
</Card>
)}
</div>
);
};

View File

@@ -1,3 +1,5 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/** /**
* getAssetBuffer is used to retrieve array buffers for various assets * getAssetBuffer is used to retrieve array buffers for various assets
* that are hosted in the `public` folder. * that are hosted in the `public` folder.
@@ -8,7 +10,7 @@
* @param path The path to the asset, relative to the `public` folder. * @param path The path to the asset, relative to the `public` folder.
*/ */
export const getAssetBuffer = async (path: string) => { export const getAssetBuffer = async (path: string) => {
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer()); return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer());
}; };

View File

@@ -0,0 +1,17 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { createNextRouter } from '@documenso/api/next';
import { ApiContractV1 } from '@documenso/api/v1/contract';
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
const nextRouteHandler = createNextRouter(ApiContractV1, ApiContractV1Implementation, {
responseValidation: true,
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// TODO: Dirty hack to make ts-rest handler work with next.js in a more intuitive way.
req.query['ts-rest'] = Array.isArray(req.query['ts-rest']) ? req.query['ts-rest'] : []; // Make `ts-rest` an array.
req.query['ts-rest'].unshift('api', 'v1'); // Prepend our base path to the array.
return await nextRouteHandler(req, res);
}

View File

@@ -0,0 +1,3 @@
import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials';
export default testCredentialsHandler;

View File

@@ -0,0 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json(OpenAPIV1);
}

View File

@@ -0,0 +1,3 @@
import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents';
export default listDocumentsHandler;

View File

@@ -0,0 +1,3 @@
import { signedDocumentHandler } from '@documenso/lib/server-only/webhooks/zapier/signed-document';
export default signedDocumentHandler;

View File

@@ -0,0 +1,3 @@
import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe';
export default subscribeHandler;

View File

@@ -0,0 +1,3 @@
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
export default unsubscribeHandler;

View File

@@ -1,7 +1,7 @@
/** @type {import('lint-staged').Config} */ /** @type {import('lint-staged').Config} */
module.exports = { module.exports = {
'**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`, '**/*.{ts,tsx,cts,mts}': (files) => files.map((file) => `eslint --fix ${file}`),
'**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`, '**/*.{js,jsx,cjs,mjs}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`, '**/*.{yml,mdx}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*/package.json': 'npm run precommit', '**/*/package.json': 'npm run precommit',
}; };

1986
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,6 @@
"apps/*", "apps/*",
"packages/*" "packages/*"
], ],
"dependencies": {},
"overrides": { "overrides": {
"next-auth": { "next-auth": {
"next": "14.0.3" "next": "14.0.3"
@@ -55,5 +54,8 @@
"next-contentlayer": { "next-contentlayer": {
"next": "14.0.3" "next": "14.0.3"
} }
},
"dependencies": {
"next-runtime-env": "^3.2.0"
} }
} }

1
packages/api/index.ts Normal file
View File

@@ -0,0 +1 @@
export {};

1
packages/api/next.ts Normal file
View File

@@ -0,0 +1 @@
export { createNextRouter } from '@ts-rest/next';

30
packages/api/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@documenso/api",
"version": "1.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rimraf node_modules"
},
"files": [
"index.ts",
"next.ts",
"v1/"
],
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5",
"@ts-rest/next": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
"@types/swagger-ui-react": "^4.18.3",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
"swagger-ui-react": "^5.11.0",
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"strict": true,
}
}

View File

@@ -0,0 +1,10 @@
'use client';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => {
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
};

191
packages/api/v1/contract.ts Normal file
View File

@@ -0,0 +1,191 @@
import { initContract } from '@ts-rest/core';
import {
ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema,
ZAuthorizationHeadersSchema,
ZCreateDocumentFromTemplateMutationResponseSchema,
ZCreateDocumentFromTemplateMutationSchema,
ZCreateDocumentMutationResponseSchema,
ZCreateDocumentMutationSchema,
ZCreateFieldMutationSchema,
ZCreateRecipientMutationSchema,
ZDeleteDocumentMutationSchema,
ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema,
ZGetDocumentsQuerySchema,
ZSuccessfulDocumentResponseSchema,
ZSuccessfulFieldResponseSchema,
ZSuccessfulGetDocumentResponseSchema,
ZSuccessfulRecipientResponseSchema,
ZSuccessfulResponseSchema,
ZSuccessfulSigningResponseSchema,
ZUnsuccessfulResponseSchema,
ZUpdateFieldMutationSchema,
ZUpdateRecipientMutationSchema,
} from './schema';
const c = initContract();
export const ApiContractV1 = c.router(
{
getDocuments: {
method: 'GET',
path: '/api/v1/documents',
query: ZGetDocumentsQuerySchema,
responses: {
200: ZSuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get all documents',
},
getDocument: {
method: 'GET',
path: '/api/v1/documents/:id',
responses: {
200: ZSuccessfulGetDocumentResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get a single document',
},
createDocument: {
method: 'POST',
path: '/api/v1/documents',
body: ZCreateDocumentMutationSchema,
responses: {
200: ZCreateDocumentMutationResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Upload a new document and get a presigned URL',
},
createDocumentFromTemplate: {
method: 'POST',
path: '/api/v1/templates/:templateId/create-document',
body: ZCreateDocumentFromTemplateMutationSchema,
responses: {
200: ZCreateDocumentFromTemplateMutationResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Upload a new document and get a presigned URL',
},
sendDocument: {
method: 'POST',
path: '/api/v1/documents/:id/send',
body: SendDocumentMutationSchema,
responses: {
200: ZSuccessfulSigningResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Send a document for signing',
},
deleteDocument: {
method: 'DELETE',
path: '/api/v1/documents/:id',
body: ZDeleteDocumentMutationSchema,
responses: {
200: ZSuccessfulDocumentResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a document',
},
createRecipient: {
method: 'POST',
path: '/api/v1/documents/:id/recipients',
body: ZCreateRecipientMutationSchema,
responses: {
200: ZSuccessfulRecipientResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a recipient for a document',
},
updateRecipient: {
method: 'PATCH',
path: '/api/v1/documents/:id/recipients/:recipientId',
body: ZUpdateRecipientMutationSchema,
responses: {
200: ZSuccessfulRecipientResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Update a recipient for a document',
},
deleteRecipient: {
method: 'DELETE',
path: '/api/v1/documents/:id/recipients/:recipientId',
body: ZDeleteRecipientMutationSchema,
responses: {
200: ZSuccessfulRecipientResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a recipient from a document',
},
createField: {
method: 'POST',
path: '/api/v1/documents/:id/fields',
body: ZCreateFieldMutationSchema,
responses: {
200: ZSuccessfulFieldResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Create a field for a document',
},
updateField: {
method: 'PATCH',
path: '/api/v1/documents/:id/fields/:fieldId',
body: ZUpdateFieldMutationSchema,
responses: {
200: ZSuccessfulFieldResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Update a field for a document',
},
deleteField: {
method: 'DELETE',
path: '/api/v1/documents/:id/fields/:fieldId',
body: ZDeleteFieldMutationSchema,
responses: {
200: ZSuccessfulFieldResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a field from a document',
},
},
{
baseHeaders: ZAuthorizationHeadersSchema,
},
);

View File

@@ -0,0 +1,59 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const { status, body } = await client.createDocument({
body: {
title: 'My Document',
recipients: [
{
name: 'John Doe',
email: 'john@example.com',
role: 'SIGNER',
},
{
name: 'Jane Doe',
email: 'jane@example.com',
role: 'APPROVER',
},
],
meta: {
subject: 'Please sign this document',
message: 'Hey {signer.name}, please sign the following document: {document.name}',
},
},
});
if (status !== 200) {
throw new Error('Failed to create document');
}
const { uploadUrl, documentId } = body;
await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
},
body: '<raw-binary-data>',
});
await client.sendDocument({
params: {
id: documentId.toString(),
},
});
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,43 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const recipientId = 1;
const { status, body } = await client.createField({
params: {
id: documentId,
},
body: {
type: 'SIGNATURE',
pageHeight: 2.5, // percent of page to occupy in height
pageWidth: 5, // percent of page to occupy in width
pageX: 10, // percent from left
pageY: 10, // percent from top
pageNumber: 1,
recipientId,
},
});
if (status !== 200) {
throw new Error('Failed to create field');
}
const { id: fieldId } = body;
console.log(`Field created with id: ${fieldId}`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,39 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const fieldId = '1';
const { status } = await client.updateField({
params: {
id: documentId,
fieldId,
},
body: {
type: 'SIGNATURE',
pageHeight: 2.5, // percent of page to occupy in height
pageWidth: 5, // percent of page to occupy in width
pageX: 10, // percent from left
pageY: 10, // percent from top
pageNumber: 1,
},
});
if (status !== 200) {
throw new Error('Failed to update field');
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,31 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const fieldId = '1';
const { status } = await client.deleteField({
params: {
id: documentId,
fieldId,
},
});
if (status !== 200) {
throw new Error('Failed to remove field');
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,38 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const { status, body } = await client.createRecipient({
params: {
id: documentId,
},
body: {
name: 'John Doe',
email: 'john@example.com',
role: 'APPROVER',
},
});
if (status !== 200) {
throw new Error('Failed to add recipient');
}
const { id: recipientId } = body;
console.log(`Recipient added with id: ${recipientId}`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,34 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const recipientId = '1';
const { status } = await client.updateRecipient({
params: {
id: documentId,
recipientId,
},
body: {
name: 'Johnathon Doe',
},
});
if (status !== 200) {
throw new Error('Failed to update recipient');
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,31 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const recipientId = '1';
const { status } = await client.deleteRecipient({
params: {
id: documentId,
recipientId,
},
});
if (status !== 200) {
throw new Error('Failed to update recipient');
}
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,31 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const documentId = '1';
const { status, body } = await client.getDocument({
params: {
id: documentId,
},
});
if (status !== 200) {
throw new Error('Failed to get document');
}
console.log(`Got document with id: ${documentId} and title: ${body.title}`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,37 @@
import { initClient } from '@ts-rest/core';
import { ApiContractV1 } from '../contract';
const main = async () => {
const client = initClient(ApiContractV1, {
baseUrl: 'http://localhost:3000/api/v1',
baseHeaders: {
authorization: 'Bearer <my-token>',
},
});
const page = 1;
const perPage = 10;
const { status, body } = await client.getDocuments({
query: {
page,
perPage,
},
});
if (status !== 200) {
throw new Error('Failed to get documents');
}
for (const document of body.documents) {
console.log(`Got document with id: ${document.id} and title: ${document.title}`);
}
console.log(`Total documents: ${body.totalPages * perPage}`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,720 @@
import { createNextRoute } from '@ts-rest/next';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { createField } from '@documenso/lib/server-only/field/create-field';
import { deleteField } from '@documenso/lib/server-only/field/delete-field';
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
import { updateField } from '@documenso/lib/server-only/field/update-field';
import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient';
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated';
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user) => {
const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10;
const { data: documents, totalPages } = await findDocuments({ page, perPage, userId: user.id });
return {
status: 200,
body: {
documents,
totalPages,
},
};
}),
getDocument: authenticatedMiddleware(async (args, user) => {
const { id: documentId } = args.params;
try {
const document = await getDocumentById({ id: Number(documentId), userId: user.id });
const recipients = await getRecipientsForDocument({
documentId: Number(documentId),
userId: user.id,
});
return {
status: 200,
body: {
...document,
recipients,
},
};
} catch (err) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
}),
deleteDocument: authenticatedMiddleware(async (args, user) => {
const { id: documentId } = args.params;
try {
const document = await getDocumentById({ id: Number(documentId), userId: user.id });
const deletedDocument = await deleteDocument({
id: Number(documentId),
userId: user.id,
status: document.status,
});
return {
status: 200,
body: deletedDocument,
};
} catch (err) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
}),
createDocument: authenticatedMiddleware(async (args, user) => {
const { body } = args;
try {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
return {
status: 500,
body: {
message: 'Create document is not available without S3 transport.',
},
};
}
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
const documentData = await createDocumentData({
data: key,
type: DocumentDataType.S3_PATH,
});
const document = await createDocument({
title: body.title,
userId: user.id,
documentDataId: documentData.id,
});
const recipients = await setRecipientsForDocument({
userId: user.id,
documentId: document.id,
recipients: body.recipients,
});
return {
status: 200,
body: {
uploadUrl: url,
documentId: document.id,
recipients: recipients.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
})),
},
};
} catch (err) {
return {
status: 404,
body: {
message: 'An error has occured while uploading the file',
},
};
}
}),
createDocumentFromTemplate: authenticatedMiddleware(async (args, user) => {
const { body, params } = args;
const templateId = Number(params.templateId);
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const document = await createDocumentFromTemplate({
templateId,
userId: user.id,
recipients: body.recipients,
});
await updateDocument({
documentId: document.id,
userId: user.id,
data: {
title: body.title,
},
});
if (body.meta) {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
subject: body.meta.subject,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
});
}
return {
status: 200,
body: {
documentId: document.id,
recipients: document.Recipient.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
})),
},
};
}),
sendDocument: authenticatedMiddleware(async (args, user) => {
const { id } = args.params;
const document = await getDocumentById({ id: Number(id), userId: user.id });
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === 'PENDING') {
return {
status: 400,
body: {
message: 'Document is already waiting for signing',
},
};
}
try {
// await setRecipientsForDocument({
// userId: user.id,
// documentId: Number(id),
// recipients: [
// {
// email: body.signerEmail,
// name: body.signerName ?? '',
// },
// ],
// });
// await setFieldsForDocument({
// documentId: Number(id),
// userId: user.id,
// fields: body.fields.map((field) => ({
// signerEmail: body.signerEmail,
// type: field.fieldType,
// pageNumber: field.pageNumber,
// pageX: field.pageX,
// pageY: field.pageY,
// pageWidth: field.pageWidth,
// pageHeight: field.pageHeight,
// })),
// });
// if (body.emailBody || body.emailSubject) {
// await upsertDocumentMeta({
// documentId: Number(id),
// subject: body.emailSubject ?? '',
// message: body.emailBody ?? '',
// });
// }
await sendDocument({
documentId: Number(id),
userId: user.id,
});
return {
status: 200,
body: {
message: 'Document sent for signing successfully',
},
};
} catch (err) {
return {
status: 500,
body: {
message: 'An error has occured while sending the document for signing',
},
};
}
}),
createRecipient: authenticatedMiddleware(async (args, user) => {
const { id: documentId } = args.params;
const { name, email, role } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const recipients = await getRecipientsForDocument({
documentId: Number(documentId),
userId: user.id,
});
const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email);
if (recipientAlreadyExists) {
return {
status: 400,
body: {
message: 'Recipient already exists',
},
};
}
try {
const newRecipients = await setRecipientsForDocument({
documentId: Number(documentId),
userId: user.id,
recipients: [
...recipients,
{
email,
name,
role,
},
],
});
const newRecipient = newRecipients.find((recipient) => recipient.email === email);
if (!newRecipient) {
throw new Error('Recipient not found');
}
return {
status: 200,
body: {
...newRecipient,
documentId: Number(documentId),
},
};
} catch (err) {
return {
status: 500,
body: {
message: 'An error has occured while creating the recipient',
},
};
}
}),
updateRecipient: authenticatedMiddleware(async (args, user) => {
const { id: documentId, recipientId } = args.params;
const { name, email, role } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const updatedRecipient = await updateRecipient({
documentId: Number(documentId),
recipientId: Number(recipientId),
email,
name,
role,
}).catch(() => null);
if (!updatedRecipient) {
return {
status: 404,
body: {
message: 'Recipient not found',
},
};
}
return {
status: 200,
body: {
...updatedRecipient,
documentId: Number(documentId),
},
};
}),
deleteRecipient: authenticatedMiddleware(async (args, user) => {
const { id: documentId, recipientId } = args.params;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const deletedRecipient = await deleteRecipient({
documentId: Number(documentId),
recipientId: Number(recipientId),
}).catch(() => null);
if (!deletedRecipient) {
return {
status: 400,
body: {
message: 'Unable to delete recipient',
},
};
}
return {
status: 200,
body: {
...deletedRecipient,
documentId: Number(documentId),
},
};
}),
createField: authenticatedMiddleware(async (args, user) => {
const { id: documentId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const recipient = await getRecipientById({
id: Number(recipientId),
documentId: Number(documentId),
}).catch(() => null);
if (!recipient) {
return {
status: 404,
body: {
message: 'Recipient not found',
},
};
}
if (recipient.signingStatus === SigningStatus.SIGNED) {
return {
status: 400,
body: {
message: 'Recipient has already signed the document',
},
};
}
const field = await createField({
documentId: Number(documentId),
recipientId: Number(recipientId),
type,
pageNumber,
pageX,
pageY,
pageWidth,
pageHeight,
});
const remappedField = {
id: field.id,
documentId: field.documentId,
recipientId: field.recipientId ?? -1,
type: field.type,
pageNumber: field.page,
pageX: Number(field.positionX),
pageY: Number(field.positionY),
pageWidth: Number(field.width),
pageHeight: Number(field.height),
customText: field.customText,
inserted: field.inserted,
};
return {
status: 200,
body: {
...remappedField,
documentId: Number(documentId),
},
};
}),
updateField: authenticatedMiddleware(async (args, user) => {
const { id: documentId, fieldId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const recipient = await getRecipientById({
id: Number(recipientId),
documentId: Number(documentId),
}).catch(() => null);
if (!recipient) {
return {
status: 404,
body: {
message: 'Recipient not found',
},
};
}
if (recipient.signingStatus === SigningStatus.SIGNED) {
return {
status: 400,
body: {
message: 'Recipient has already signed the document',
},
};
}
const updatedField = await updateField({
fieldId: Number(fieldId),
documentId: Number(documentId),
recipientId: recipientId ? Number(recipientId) : undefined,
type,
pageNumber,
pageX,
pageY,
pageWidth,
pageHeight,
});
const remappedField = {
id: updatedField.id,
documentId: updatedField.documentId,
recipientId: updatedField.recipientId ?? -1,
type: updatedField.type,
pageNumber: updatedField.page,
pageX: Number(updatedField.positionX),
pageY: Number(updatedField.positionY),
pageWidth: Number(updatedField.width),
pageHeight: Number(updatedField.height),
customText: updatedField.customText,
inserted: updatedField.inserted,
};
return {
status: 200,
body: {
...remappedField,
documentId: Number(documentId),
},
};
}),
deleteField: authenticatedMiddleware(async (args, user) => {
const { id: documentId, fieldId } = args.params;
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (document.status === DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is already completed',
},
};
}
const field = await getFieldById({
fieldId: Number(fieldId),
documentId: Number(documentId),
}).catch(() => null);
if (!field) {
return {
status: 404,
body: {
message: 'Field not found',
},
};
}
const recipient = await getRecipientById({
id: Number(field.recipientId),
documentId: Number(documentId),
}).catch(() => null);
if (recipient?.signingStatus === SigningStatus.SIGNED) {
return {
status: 400,
body: {
message: 'Recipient has already signed the document',
},
};
}
const deletedField = await deleteField({
documentId: Number(documentId),
fieldId: Number(fieldId),
}).catch(() => null);
if (!deletedField) {
return {
status: 400,
body: {
message: 'Unable to delete field',
},
};
}
const remappedField = {
id: deletedField.id,
documentId: deletedField.documentId,
recipientId: deletedField.recipientId ?? -1,
type: deletedField.type,
pageNumber: deletedField.page,
pageX: Number(deletedField.positionX),
pageY: Number(deletedField.positionY),
pageWidth: Number(deletedField.width),
pageHeight: Number(deletedField.height),
customText: deletedField.customText,
inserted: deletedField.inserted,
};
return {
status: 200,
body: {
...remappedField,
documentId: Number(documentId),
},
};
}),
});

View File

@@ -0,0 +1,41 @@
import type { NextApiRequest } from 'next';
import { getUserByApiToken } from '@documenso/lib/server-only/public-api/get-user-by-token';
import type { User } from '@documenso/prisma/client';
export const authenticatedMiddleware = <
T extends {
req: NextApiRequest;
},
R extends {
status: number;
body: unknown;
},
>(
handler: (args: T, user: User) => Promise<R>,
) => {
return async (args: T) => {
try {
const { authorization } = args.req.headers;
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new Error('Token was not provided for authenticated middleware');
}
const user = await getUserByApiToken({ token });
return await handler(args, user);
} catch (_err) {
console.log({ _err });
return {
status: 401,
body: {
message: 'Unauthorized',
},
} as const;
}
};
};

View File

@@ -0,0 +1,17 @@
import { generateOpenApi } from '@ts-rest/open-api';
import { ApiContractV1 } from './contract';
export const OpenAPIV1 = generateOpenApi(
ApiContractV1,
{
info: {
title: 'Documenso API',
version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
},
},
{
setOperationId: true,
},
);

240
packages/api/v1/schema.ts Normal file
View File

@@ -0,0 +1,240 @@
import { z } from 'zod';
import {
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
/**
* Documents
*/
export const ZGetDocumentsQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
});
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
export const ZDeleteDocumentMutationSchema = null;
export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema;
export const ZSuccessfulDocumentResponseSchema = z.object({
id: z.number(),
userId: z.number(),
title: z.string(),
status: z.string(),
documentDataId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
completedAt: z.date().nullable(),
});
export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseSchema.extend({
recipients: z.lazy(() => z.array(ZSuccessfulRecipientResponseSchema)),
});
export type TSuccessfulGetDocumentResponseSchema = z.infer<
typeof ZSuccessfulGetDocumentResponseSchema
>;
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
export const ZSendDocumentForSigningMutationSchema = null;
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
export const ZUploadDocumentSuccessfulSchema = z.object({
url: z.string(),
key: z.string(),
});
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
meta: z
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z.string(),
})
.partial(),
});
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
export const ZCreateDocumentMutationResponseSchema = z.object({
uploadUrl: z.string().min(1),
documentId: z.number(),
recipients: z.array(
z.object({
recipientId: z.number(),
token: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});
export type TCreateDocumentMutationResponseSchema = z.infer<
typeof ZCreateDocumentMutationResponseSchema
>;
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
title: z.string().min(1),
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
meta: z
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z.string(),
})
.partial()
.optional(),
});
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationSchema
>;
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
});
export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationResponseSchema
>;
export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
});
/**
* Recipients
*/
export type TCreateRecipientMutationSchema = z.infer<typeof ZCreateRecipientMutationSchema>;
export const ZUpdateRecipientMutationSchema = ZCreateRecipientMutationSchema.partial();
export type TUpdateRecipientMutationSchema = z.infer<typeof ZUpdateRecipientMutationSchema>;
export const ZDeleteRecipientMutationSchema = null;
export type TDeleteRecipientMutationSchema = typeof ZDeleteRecipientMutationSchema;
export const ZSuccessfulRecipientResponseSchema = z.object({
id: z.number(),
// !: This handles the fact that we have null documentId's for templates
// !: while we won't need the default we must add it to satisfy typescript
documentId: z.number().nullish().default(-1),
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
token: z.string(),
// !: Not used for now
// expired: z.string(),
signedAt: z.date().nullable(),
readStatus: z.nativeEnum(ReadStatus),
signingStatus: z.nativeEnum(SigningStatus),
sendStatus: z.nativeEnum(SendStatus),
});
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
/**
* Fields
*/
export const ZCreateFieldMutationSchema = z.object({
recipientId: z.number(),
type: z.nativeEnum(FieldType),
pageNumber: z.number(),
pageX: z.number(),
pageY: z.number(),
pageWidth: z.number(),
pageHeight: z.number(),
});
export type TCreateFieldMutationSchema = z.infer<typeof ZCreateFieldMutationSchema>;
export const ZUpdateFieldMutationSchema = ZCreateFieldMutationSchema.partial();
export type TUpdateFieldMutationSchema = z.infer<typeof ZUpdateFieldMutationSchema>;
export const ZDeleteFieldMutationSchema = null;
export type TDeleteFieldMutationSchema = typeof ZDeleteFieldMutationSchema;
export const ZSuccessfulFieldResponseSchema = z.object({
id: z.number(),
documentId: z.number(),
recipientId: z.number(),
type: z.nativeEnum(FieldType),
pageNumber: z.number(),
pageX: z.number(),
pageY: z.number(),
pageWidth: z.number(),
pageHeight: z.number(),
customText: z.string(),
inserted: z.boolean(),
});
export type TSuccessfulFieldResponseSchema = z.infer<typeof ZSuccessfulFieldResponseSchema>;
export const ZSuccessfulResponseSchema = z.object({
documents: ZSuccessfulDocumentResponseSchema.array(),
totalPages: z.number(),
});
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
export const ZSuccessfulSigningResponseSchema = z.object({
message: z.string(),
});
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
/**
* General
*/
export const ZAuthorizationHeadersSchema = z.object({
authorization: z.string(),
});
export type TAuthorizationHeadersSchema = z.infer<typeof ZAuthorizationHeadersSchema>;
export const ZUnsuccessfulResponseSchema = z.object({
message: z.string(),
});
export type TUnsuccessfulResponseSchema = z.infer<typeof ZUnsuccessfulResponseSchema>;

View File

@@ -12,7 +12,7 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => { export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {}; const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`); const url = new URL('/api/limits', APP_BASE_URL() ?? 'http://localhost:3000');
if (teamId) { if (teamId) {
requestHeaders['team-id'] = teamId.toString(); requestHeaders['team-id'] = teamId.toString();

View File

@@ -1,11 +1,10 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client'; import { SubscriptionStatus } from '@documenso/prisma/client';
import { getPricesByPlan } from '../stripe/get-prices-by-plan'; import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors'; import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema'; import { ZLimitsSchema } from './schema';
@@ -16,7 +15,7 @@ export type GetServerLimitsOptions = {
}; };
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => { export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
if (!IS_BILLING_ENABLED) { if (!IS_BILLING_ENABLED()) {
return { return {
quota: SELFHOSTED_PLAN_LIMITS, quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS, remaining: SELFHOSTED_PLAN_LIMITS,
@@ -56,10 +55,11 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
); );
if (activeSubscriptions.length > 0) { if (activeSubscriptions.length > 0) {
const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY); const documentPlanPrices = await getDocumentRelatedPrices();
for (const subscription of activeSubscriptions) { for (const subscription of activeSubscriptions) {
const price = communityPlanPrices.find((price) => price.id === subscription.priceId); const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) { if (!price || typeof price.product === 'string' || price.product.deleted) {
continue; continue;
} }

View File

@@ -0,0 +1,10 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of documents a user can create.
*/
export const getDocumentRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};

View File

@@ -0,0 +1,13 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getEnterprisePlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
};
export const getEnterprisePlanPriceIds = async () => {
const prices = await getEnterprisePlanPrices();
return prices.map((price) => price.id);
};

View File

@@ -1,14 +1,18 @@
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
export const getPricesByPlan = async ( type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE],
) => { export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes = typeof plan === 'string' ? [plan] : plan;
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
const { data: prices } = await stripe.prices.search({ const { data: prices } = await stripe.prices.search({
query: `metadata['plan']:'${plan}' type:'recurring'`, query,
expand: ['data.product'], expand: ['data.product'],
limit: 100, limit: 100,
}); });
return prices; return prices.filter((price) => price.type === 'recurring');
}; };

View File

@@ -0,0 +1,10 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the prices of items that count as the account's primary plan.
*/
export const getPrimaryAccountPlanPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};

View File

@@ -0,0 +1,17 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};
/**
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPriceIds = async () => {
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
};

View File

@@ -2,13 +2,13 @@ import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing'; import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { type Subscription, type Team, type User } from '@documenso/prisma/client'; import { type Subscription, type Team, type User } from '@documenso/prisma/client';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods'; import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getCommunityPlanPriceIds } from './get-community-plan-prices';
import { getTeamPrices } from './get-team-prices'; import { getTeamPrices } from './get-team-prices';
import { getTeamRelatedPriceIds } from './get-team-related-prices';
type TransferStripeSubscriptionOptions = { type TransferStripeSubscriptionOptions = {
/** /**
@@ -46,14 +46,14 @@ export const transferTeamSubscription = async ({
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.'); throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
} }
const [communityPlanIds, teamSeatPrices] = await Promise.all([ const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
getCommunityPlanPriceIds(), getTeamRelatedPriceIds(),
getTeamPrices(), getTeamPrices(),
]); ]);
const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan( const teamSubscriptionRequired = !subscriptionsContainsActivePlan(
user.Subscription, user.Subscription,
communityPlanIds, teamRelatedPlanPriceIds,
); );
let teamSubscription: Stripe.Subscription | null = null; let teamSubscription: Stripe.Subscription | null = null;

View File

@@ -1,3 +1,5 @@
import { env } from 'next-runtime-env';
import { Button, Column, Img, Link, Section, Text } from '../components'; import { Button, Column, Img, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image'; import { TemplateDocumentImage } from './template-document-image';
@@ -10,7 +12,9 @@ export const TemplateDocumentSelfSigned = ({
documentName, documentName,
assetBaseUrl, assetBaseUrl,
}: TemplateDocumentSelfSignedProps) => { }: TemplateDocumentSelfSignedProps) => {
const signUpUrl = `${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`; const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const getAssetUrl = (path: string) => { const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString(); return new URL(path, assetBaseUrl).toString();

View File

@@ -1,3 +1,5 @@
import { env } from 'next-runtime-env';
import { Button, Section, Text } from '../components'; import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image'; import { TemplateDocumentImage } from './template-document-image';
@@ -8,6 +10,8 @@ export interface TemplateResetPasswordProps {
} }
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => { export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
return ( return (
<> <>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} /> <TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@@ -24,7 +28,7 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
<Section className="mb-6 mt-8 text-center"> <Section className="mb-6 mt-8 text-center">
<Button <Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline" className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`} href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
> >
Sign In Sign In
</Button> </Button>

View File

@@ -0,0 +1,19 @@
export type DownloadFileOptions = {
filename: string;
data: Blob;
};
export const downloadFile = ({ filename, data }: DownloadFileOptions) => {
if (typeof window === 'undefined') {
throw new Error('downloadFile can only be called in browser environments');
}
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(data);
link.download = filename;
link.click();
window.URL.revokeObjectURL(link.href);
};

View File

@@ -1,6 +1,7 @@
import type { DocumentData } from '@documenso/prisma/client'; import type { DocumentData } from '@documenso/prisma/client';
import { getFile } from '../universal/upload/get-file'; import { getFile } from '../universal/upload/get-file';
import { downloadFile } from './download-file';
type DownloadPDFProps = { type DownloadPDFProps = {
documentData: DocumentData; documentData: DocumentData;
@@ -14,16 +15,12 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
type: 'application/pdf', type: 'application/pdf',
}); });
const link = window.document.createElement('a');
const [baseTitle] = fileName?.includes('.pdf') const [baseTitle] = fileName?.includes('.pdf')
? fileName.split('.pdf') ? fileName.split('.pdf')
: [fileName ?? 'document']; : [fileName ?? 'document'];
link.href = window.URL.createObjectURL(blob); downloadFile({
link.download = `${baseTitle}_signed.pdf`; filename: baseTitle,
data: blob,
link.click(); });
window.URL.revokeObjectURL(link.href);
}; };

View File

@@ -0,0 +1,13 @@
import type { EffectCallback } from 'react';
import { useEffect } from 'react';
/**
* Dangerously runs an effect "once" by ignoring the depedencies of a given effect.
*
* DANGER: The effect will run twice in concurrent react and development environments.
*/
export const unsafe_useEffectOnce = (callback: EffectCallback) => {
// Intentionally avoiding exhaustive deps and rule of hooks here
// eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/rules-of-hooks
return useEffect(callback, []);
};

View File

@@ -1,16 +1,19 @@
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing'; import { env } from 'next-runtime-env';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50; Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web'; export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL');
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
export const APP_BASE_URL = IS_APP_WEB export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
? process.env.NEXT_PUBLIC_WEBAPP_URL export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
: process.env.NEXT_PUBLIC_MARKETING_URL; export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
export const WEBAPP_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'; export const APP_FOLDER = () => (IS_APP_MARKETING ? 'marketing' : 'web');
export const MARKETING_BASE_URL = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001'; export const APP_BASE_URL = () =>
IS_APP_WEB ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL();
export const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000';
export const MARKETING_BASE_URL = NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001';

View File

@@ -6,6 +6,5 @@ export enum STRIPE_CUSTOMER_TYPE {
export enum STRIPE_PLAN_TYPE { export enum STRIPE_PLAN_TYPE {
TEAM = 'team', TEAM = 'team',
COMMUNITY = 'community', COMMUNITY = 'community',
ENTERPRISE = 'enterprise',
} }
export const TEAM_BILLING_DOMAIN = 'billing.team.documenso.com';

View File

@@ -1,5 +1,10 @@
import { env } from 'next-runtime-env';
import { APP_BASE_URL } from './app'; import { APP_BASE_URL } from './app';
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
/** /**
* The flag name for global session recording feature flag. * The flag name for global session recording feature flag.
*/ */
@@ -16,8 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account. * Does not take any person or group properties into account.
*/ */
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = { export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true', app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_teams: true,
marketing_header_single_player_mode: false, marketing_header_single_player_mode: false,
} as const; } as const;
@@ -25,8 +29,8 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
* Extract the PostHog configuration from the environment. * Extract the PostHog configuration from the environment.
*/ */
export function extractPostHogConfig(): { key: string; host: string } | null { export function extractPostHogConfig(): { key: string; host: string } | null {
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; const postHogKey = NEXT_PUBLIC_POSTHOG_KEY();
const postHogHost = `${APP_BASE_URL}/ingest`; const postHogHost = `${APP_BASE_URL()}/ingest`;
if (!postHogKey || !postHogHost) { if (!postHogKey || !postHogHost) {
return null; return null;

View File

@@ -6,4 +6,4 @@ export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const MIN_STANDARD_FONT_SIZE = 8; export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20; export const MIN_HANDWRITING_FONT_SIZE = 20;
export const CAVEAT_FONT_PATH = `${APP_BASE_URL}/fonts/caveat.ttf`; export const CAVEAT_FONT_PATH = () => `${APP_BASE_URL()}/fonts/caveat.ttf`;

Some files were not shown because too many files have changed in this diff Show More