Compare commits
102 Commits
v1.4.0-rc.
...
webhooks_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9b2abcadd | ||
|
|
99a26065a8 | ||
|
|
91375a17c2 | ||
|
|
a0aeca48f2 | ||
|
|
df132a51ab | ||
|
|
8287722f59 | ||
|
|
aba6b58c14 | ||
|
|
b6c9213b66 | ||
|
|
22e3a79a72 | ||
|
|
b9e5905469 | ||
|
|
39c6cbf66a | ||
|
|
c6dbaaea21 | ||
|
|
0186f2dfed | ||
|
|
2815b1a809 | ||
|
|
5d6cdbef89 | ||
|
|
26d4bbf010 | ||
|
|
960914aeb5 | ||
|
|
d83769b410 | ||
|
|
cd240ae8a4 | ||
|
|
a1459b41fd | ||
|
|
a0cf2a2c75 | ||
|
|
a30b73ce86 | ||
|
|
46d163d9d6 | ||
|
|
681a89cfe1 | ||
|
|
4d6e780abe | ||
|
|
7f3f6f5312 | ||
|
|
019db27b1d | ||
|
|
e5f4edc120 | ||
|
|
25291b64eb | ||
|
|
fe2093fe7c | ||
|
|
49cddfab38 | ||
|
|
3e12a05ab8 | ||
|
|
a76504c0a4 | ||
|
|
abab0c0a22 | ||
|
|
61958989b4 | ||
|
|
4c5b910a59 | ||
|
|
1a82740d0f | ||
|
|
51608ed390 | ||
|
|
8ebef831ac | ||
|
|
20e2976731 | ||
|
|
0209127136 | ||
|
|
ddb9dd11d7 | ||
|
|
b3ba77dfed | ||
|
|
e91bb78f2d | ||
|
|
748bf6de6b | ||
|
|
d13cf743bf | ||
|
|
98df273ebc | ||
|
|
b3514bd0c7 | ||
|
|
edeeaa5651 | ||
|
|
c970abc871 | ||
|
|
d5b3df1648 | ||
|
|
142c1c003e | ||
|
|
a06c628653 | ||
|
|
7ca3697303 | ||
|
|
8ac2209493 | ||
|
|
9c4ec34a3c | ||
|
|
1f142e334a | ||
|
|
08f82b23dc | ||
|
|
747a7b0aea | ||
|
|
375df71f5c | ||
|
|
c0bb5205e1 | ||
|
|
927a656c57 | ||
|
|
751fb5275c | ||
|
|
2f18518961 | ||
|
|
d451a7acce | ||
|
|
d8aecc4092 | ||
|
|
e5c2263e92 | ||
|
|
5a28eaa4ff | ||
|
|
b6aface982 | ||
|
|
b28a7f9702 | ||
|
|
3b82ba57f3 | ||
|
|
a1215df91a | ||
|
|
d283cc2d26 | ||
|
|
6a56905fea | ||
|
|
a22ada5f41 | ||
|
|
fb46b09e4f | ||
|
|
17486b961d | ||
|
|
da03fc1fd0 | ||
|
|
19736ce60b | ||
|
|
e79d385534 | ||
|
|
8ecd8a7d10 | ||
|
|
66c0db91da | ||
|
|
54401b94ae | ||
|
|
11ae6d3c16 | ||
|
|
6c5526dd49 | ||
|
|
936e75fd30 | ||
|
|
6be4b7ae90 | ||
|
|
76800674ee | ||
|
|
d43d40fd6b | ||
|
|
e1732de81d | ||
|
|
6a5fc7a5fb | ||
|
|
13997d3dca | ||
|
|
2deaad5c34 | ||
|
|
fbee6eedc1 | ||
|
|
80fe7ccdf5 | ||
|
|
2ccede72ea | ||
|
|
309b56168a | ||
|
|
5c8a77ee8f | ||
|
|
b3008fb272 | ||
|
|
6d6c93539f | ||
|
|
4a6b3edc05 | ||
|
|
24d9906557 |
@@ -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
7
.well-known/security.txt
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
7
apps/marketing/public/.well-known/security.txt
Normal file
7
apps/marketing/public/.well-known/security.txt
Normal 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
|
||||||
56591
apps/marketing/public/pdf.worker.min.js
vendored
56591
apps/marketing/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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 };
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>{' '}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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": "*",
|
||||||
|
|||||||
7
apps/web/public/.well-known/security.txt
Normal file
7
apps/web/public/.well-known/security.txt
Normal 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
|
||||||
@@ -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>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
74
apps/web/src/app/(dashboard)/settings/tokens/page.tsx
Normal file
74
apps/web/src/app/(dashboard)/settings/tokens/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
Normal file
169
apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
apps/web/src/app/(dashboard)/settings/webhooks/page.tsx
Normal file
82
apps/web/src/app/(dashboard)/settings/webhooks/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
apps/web/src/app/api/v1/openapi/page.tsx
Normal file
3
apps/web/src/app/api/v1/openapi/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export { OpenApiDocsPage as default } from '@documenso/api/v1/api-documentation';
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))
|
))
|
||||||
|
|||||||
255
apps/web/src/components/forms/token.tsx
Normal file
255
apps/web/src/components/forms/token.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
0
apps/web/src/components/forms/webhook.tsx
Normal file
0
apps/web/src/components/forms/webhook.tsx
Normal 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());
|
||||||
};
|
};
|
||||||
|
|||||||
17
apps/web/src/pages/api/v1/[...ts-rest].tsx
Normal file
17
apps/web/src/pages/api/v1/[...ts-rest].tsx
Normal 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);
|
||||||
|
}
|
||||||
3
apps/web/src/pages/api/v1/me/index.ts
Normal file
3
apps/web/src/pages/api/v1/me/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials';
|
||||||
|
|
||||||
|
export default testCredentialsHandler;
|
||||||
7
apps/web/src/pages/api/v1/openapi.json.ts
Normal file
7
apps/web/src/pages/api/v1/openapi.json.ts
Normal 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);
|
||||||
|
}
|
||||||
3
apps/web/src/pages/api/v1/zapier/list-documents/index.ts
Normal file
3
apps/web/src/pages/api/v1/zapier/list-documents/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents';
|
||||||
|
|
||||||
|
export default listDocumentsHandler;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { signedDocumentHandler } from '@documenso/lib/server-only/webhooks/zapier/signed-document';
|
||||||
|
|
||||||
|
export default signedDocumentHandler;
|
||||||
3
apps/web/src/pages/api/v1/zapier/subscribe/index.ts
Normal file
3
apps/web/src/pages/api/v1/zapier/subscribe/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe';
|
||||||
|
|
||||||
|
export default subscribeHandler;
|
||||||
3
apps/web/src/pages/api/v1/zapier/unsubscribe/index.ts
Normal file
3
apps/web/src/pages/api/v1/zapier/unsubscribe/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
|
||||||
|
|
||||||
|
export default unsubscribeHandler;
|
||||||
@@ -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
1986
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
1
packages/api/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
1
packages/api/next.ts
Normal file
1
packages/api/next.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { createNextRouter } from '@ts-rest/next';
|
||||||
30
packages/api/package.json
Normal file
30
packages/api/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/api/tsconfig.json
Normal file
8
packages/api/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@documenso/tsconfig/react-library.json",
|
||||||
|
"include": ["."],
|
||||||
|
"exclude": ["dist", "build", "node_modules"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/api/v1/api-documentation.tsx
Normal file
10
packages/api/v1/api-documentation.tsx
Normal 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
191
packages/api/v1/contract.ts
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
59
packages/api/v1/examples/01-create-and-send-document.ts
Normal file
59
packages/api/v1/examples/01-create-and-send-document.ts
Normal 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);
|
||||||
|
});
|
||||||
43
packages/api/v1/examples/02-add-a-field.ts
Normal file
43
packages/api/v1/examples/02-add-a-field.ts
Normal 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);
|
||||||
|
});
|
||||||
39
packages/api/v1/examples/03-update-a-field.ts
Normal file
39
packages/api/v1/examples/03-update-a-field.ts
Normal 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);
|
||||||
|
});
|
||||||
31
packages/api/v1/examples/04-remove-a-field.ts
Normal file
31
packages/api/v1/examples/04-remove-a-field.ts
Normal 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);
|
||||||
|
});
|
||||||
38
packages/api/v1/examples/05-add-a-recipient.ts
Normal file
38
packages/api/v1/examples/05-add-a-recipient.ts
Normal 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);
|
||||||
|
});
|
||||||
34
packages/api/v1/examples/06-update-a-recipient.ts
Normal file
34
packages/api/v1/examples/06-update-a-recipient.ts
Normal 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);
|
||||||
|
});
|
||||||
31
packages/api/v1/examples/07-remove-a-recipient.ts
Normal file
31
packages/api/v1/examples/07-remove-a-recipient.ts
Normal 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);
|
||||||
|
});
|
||||||
31
packages/api/v1/examples/08-get-a-document.ts
Normal file
31
packages/api/v1/examples/08-get-a-document.ts
Normal 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);
|
||||||
|
});
|
||||||
37
packages/api/v1/examples/09-paginate-all-documents.ts
Normal file
37
packages/api/v1/examples/09-paginate-all-documents.ts
Normal 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);
|
||||||
|
});
|
||||||
720
packages/api/v1/implementation.ts
Normal file
720
packages/api/v1/implementation.ts
Normal 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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
41
packages/api/v1/middleware/authenticated.ts
Normal file
41
packages/api/v1/middleware/authenticated.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
17
packages/api/v1/openapi.ts
Normal file
17
packages/api/v1/openapi.ts
Normal 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
240
packages/api/v1/schema.ts
Normal 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>;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
};
|
||||||
13
packages/ee/server-only/stripe/get-enterprise-plan-prices.ts
Normal file
13
packages/ee/server-only/stripe/get-enterprise-plan-prices.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
};
|
||||||
17
packages/ee/server-only/stripe/get-team-related-prices.ts
Normal file
17
packages/ee/server-only/stripe/get-team-related-prices.ts
Normal 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));
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
19
packages/lib/client-only/download-file.ts
Normal file
19
packages/lib/client-only/download-file.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
};
|
};
|
||||||
|
|||||||
13
packages/lib/client-only/hooks/use-effect-once.ts
Normal file
13
packages/lib/client-only/hooks/use-effect-once.ts
Normal 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, []);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user