Compare commits

..

12 Commits

Author SHA1 Message Date
Timur Ercan
5362fad01a chore: typo 2024-02-26 12:23:46 +01:00
Timur Ercan
9319a89056 chore: text 2024-02-26 12:14:05 +01:00
Timur Ercan
2f8a665feb chore: text 2024-02-26 10:48:03 +01:00
Timur Ercan
5a1e671882 chore: text 2024-02-26 10:47:47 +01:00
Timur Ercan
69d695972b chore: update video link 2024-02-26 10:42:42 +01:00
Timur Ercan
f4575217c6 chore: typos 2024-02-23 12:32:13 +01:00
Timur Ercan
6422bca525 chore: typos 2024-02-22 19:51:24 +01:00
Timur Ercan
a306bf3524 chore: typos and content day5 2024-02-22 16:03:37 +01:00
Timur Ercan
21056fb39c chore: launchweek content 2024-02-19 16:05:36 +01:00
Timur Ercan
c7f109203d feat: launch week #2 teaser 2024-02-07 15:25:45 +01:00
Timur Ercan
c451b2d871 fix: tags 2024-02-07 15:19:49 +01:00
Timur Ercan
63c4e0af80 fix: tags 2024-02-07 15:19:38 +01:00
369 changed files with 58553 additions and 15205 deletions

View File

@@ -10,13 +10,7 @@
"ghcr.io/devcontainers/features/node:1": {}
},
"onCreateCommand": "./.devcontainer/on-create.sh",
"forwardPorts": [
3000,
54320,
9000,
2500,
1100
],
"forwardPorts": [3000, 54320, 9000, 2500, 1100],
"customizations": {
"vscode": {
"extensions": [
@@ -31,8 +25,8 @@
"GitHub.copilot",
"GitHub.vscode-pull-request-github",
"Prisma.prisma",
"VisualStudioExptTeam.vscodeintellicode"
"VisualStudioExptTeam.vscodeintellicode",
]
}
}
}
}

View File

@@ -1,16 +1,4 @@
#!/usr/bin/env 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

View File

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

View File

@@ -1,4 +1,4 @@
> 🚨 It is Launch Week #2 - Day 1: We launches teams 🎉 https://documen.so/day1
>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">

View File

@@ -24,8 +24,6 @@ tags:
</figcaption>
</figure>
> 🔔 UPDATE: We launched <a href="https://documen.so/day1" target="_blank">teams</a> and the early adopters plan will be replaced by the new teams pricing as soon as all availible early adopters seats are filled.
## Community-Driven Development
As we ramp up hiring and development speed for Documenso, I want to discuss how we plan to build its core version.

View File

@@ -7,8 +7,8 @@ authorRole: 'Co-Founder'
date: 2024-02-26
tags:
- Launch Week
- Teams
- Early Adopter Perks
- teams
- early adopter perks
---
<video
@@ -35,7 +35,7 @@ You can now create teams next to your personal account: Simply invite your colle
- Send unlimited signature requests with unlimited recipients
- Create, view, edit and sign documents owned by the team
- Define a dedicated team email, to receive signing requests into a team inbox for the owner to sign
- Manage team roles: Member (Create+Edit), Managers (+Manage Team Members), Owner (+Transfer Team +Delete Team + Sign Documents sent to team email)
- Manage team roles: Member (Create+Edit), Manger Manage (+Manage Team Members), Owner (+Transfer Team +Delete Team + Sign Documents sent to team email)
## Pricing

View File

@@ -7,7 +7,8 @@ authorRole: 'Co-Founder'
date: 2024-02-27
tags:
- Launch Week
- Templates
- secret
- no spoiler
---
<video
@@ -24,38 +25,11 @@ tags:
## Introducing Templates
It's day 2 of Launch Week, everybody 🙌 After introducing [Teams](https://documenso.com/blog/launch-week-2-day-1) yesterday, today we are looking at making Documenso faster for daily use:
We are launching templates for Documenso! Templates are an easy way to reuse documents you send out often with just a few clicks. With templates, you can:
<figure>
<MdxNextImage
src="/blog/quickfill.png"
width="1260"
height="630"
alt="Template recipients quick fill view"
/>
<figcaption className="text-center">
Quickly fill out recipients, when creating from a template
</figcaption>
</figure>
We are launching templates for Documenso! Templates are an easy to reuse documents you send out often with just a few clicks. With templates, you can:
- Save often-uploaded documents for reuse
- Pre-define fields, so you just have to send the document
- Quickly fill out recipients and roles for new documents
- Share templates with your team to make working together even easier
<figure>
<MdxNextImage
src="/blog/template.png"
width="1260"
height="630"
alt="Create from template view"
/>
<figcaption className="text-center">
POV: You are a diligent german and create custom receipts with Documenso.
</figcaption>
</figure>
- Define fields once and just fill them out before sending (if at all)
- Create and share templates with your team
## Pricing
@@ -63,12 +37,13 @@ Templates are **included in all Documenso Plans!** That includes our free tier:
## What's Next for Templates
We have a lot of great stuff coming up for templates as well:
While this is all great, we have a lot of great stuff coming up for templates:
- More Field Types are in the pipeline
- More Field Types
- Custom Field Types Defined by You
- Sharing Templates Externally 👀
Check out templates [here](https://documen.so/templates) and let us know what you think and what we can improve. Which field types are you missing? Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
Check out templates [here](https://documen.so/templates) and let us know what you think and what we can improve. Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 2! They just launched templates, and I'm pumped 🎉🚀🚀🚀. Check it out https://documen.so/day2"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀

View File

@@ -0,0 +1,48 @@
---
title: Launch Week II - Day 3 - API
description: Documenso's mission is to create a plattform developers all around the world can build upon. Today we are releasing the first version of our public API, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-28
tags:
- Launch Week
- API
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/cb74d6cb-a127-4cac-a166-ad6b56c6140d"
autoPlay
loop
muted
></video>
> TLDR; The public API is now availible for all plans.
## Introducing the public Documenso API
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. Since this is the first version, we focused on the basics. With the new API you can:
- Get Documents (Individual or all Accessible)
- Upload documents
- Delete Documents
- Trigger Sending Documents for Singing
You can check out the detailed API documentation here: TODO API DOCS URL
## Pricing
We are building Documenso to be an open and extendable platform; therefore, therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
## What's next for the API
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

View File

@@ -0,0 +1,48 @@
---
title: Launch Week II - Day 4 - Zapier
description: If you want to integrate Documenso without fiddling with the API, we got you as well. You can now integrate Documenso via Zapier, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-29
tags:
- Launch Week
- Zapier
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/3b60789d-8d27-4c66-ae5c-179e33c2e3e6"
autoPlay
loop
muted
></video>
> TLDR; The public API is now availible for all plans.
## Introducing the public Documenso API
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform, developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. This since this is the first version we focused on the basics. The API is With the new API you can:
- Get Documents (Individual or all Accessible)
- Upload documents
- Delete Documents
- Trigger Sending Documents for Singing
You can check out the detailed API documentation here: TODO API DOCS URL
## Pricing
We are building Documenso to be an open and extendable platform; therefore, the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
## What's next for the API
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

View File

@@ -0,0 +1,59 @@
---
title: Launch Week II - Day 5 - Documenso Profiles
description: Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles will launch as soon as they are shiny.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-03-01
tags:
- Launch Week
- Profiles
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/89643ae2-2aa9-484c-a522-a0e35097c469"
autoPlay
loop
muted
></video>
> TLDR; Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles launch as soon as they are shiny.
## Introducing Documenso Profile Links
Signing documents has always been between humans, and signing something together should be as frictionless as possible. It should also be async, so you don't force your counterpart to jog to their device to send something when you are ready. Today we are announcing the new Documenso Profiles:
<figure>
<MdxNextImage
src="/blog/profile.png"
width="650"
height="100"
alt="lorem"
/>
<figcaption className="text-center">
Async > Sync: Add public templates to your Documenso Link and let people sign whenever they are ready.
</figcaption>
</figure>
Documenso profiles work with your existing templates. You can just add them to your public profile to let everyone with your link sign them. With profiles, we want to bring back the human aspect of signing.
By making profiles public, you can always access what your counterparty offers and make them more visible in the process. Long-term, we plan to add more to profiles to help you ensure the person you are dealing with is who they claim to be. Documenso wants to be the trust layer of the internet, and we want to start at the very fundamental level: The individual transaction.
Profiles are our first step towards bringing more trust into everything, simply by making the use of signing more frictionless. As there is more and more content of questionable origin out there, we want to support you in making it clear what you send out and what not.
## Pricing and Claiming
Documenso profile username can be claimed starting today. Documenso profiles will launch as soon as we are happy with the details ✨
- Long usernames (6 characters or more) come free with every account, e.g. **documenso.com/u/timurercan**
- Short usernames (5 characters or fewer) or less require any paid account ([Early Adopter](https://documen.so/claim-early-adopters-plan), [Teams](https://documen.so/teams) or Enterprise): **e.g., documenso.com/u/timur**
You can claim your username here: [https://documen.so/claim](https://documen.so/claim)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 5! You can now claim your username for the upcoming profile links 😮 https://documen.so/day5"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur

View File

@@ -1,7 +0,0 @@
# 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
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 { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client';
@@ -86,7 +85,6 @@ export const SinglePlayerClient = () => {
setFields(
data.fields.map((field, i) => ({
id: i,
secondaryId: i.toString(),
documentId: -1,
templateId: null,
recipientId: -1,
@@ -191,7 +189,7 @@ export const SinglePlayerClient = () => {
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '}
<Link
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
@@ -256,7 +254,6 @@ export const SinglePlayerClient = () => {
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireCustomText={Boolean(fields.find((field) => field.type === 'TEXT'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
</Stepper>

View File

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

View File

@@ -2,10 +2,7 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { PublicEnvScript } from 'next-runtime-env';
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 { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,35 +17,32 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export function generateMetadata() {
return {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
export const metadata = {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3000'),
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: ['/opengraph-image.jpg'],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: ['/opengraph-image.jpg'],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
}
type: 'website',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getAllAnonymousFlags();
@@ -64,7 +58,6 @@ 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="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<PublicEnvScript />
</head>
<Suspense>

View File

@@ -8,7 +8,6 @@ import Link from 'next/link';
import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -83,7 +82,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="rounded-full text-base" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6">
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
target="_blank"
className="mt-6"
>
Signup Now
</Link>
</Button>
@@ -114,28 +117,29 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="mt-6 rounded-full text-base" asChild>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank">
Signup Now
</Link>
<Link href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}>Signup Now</Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4">
<a
href="https://documen.so/early-adopters-pricing-page"
target="_blank"
rel="noreferrer"
>
Limited Time Offer: <span className="text-documenso-700">Read More</span>
<p className="text-foreground py-4 font-medium">
{' '}
<a href="https://documenso.com/blog/early-adopters" target="_blank">
The Early Adopter Deal:
</a>
</p>
<p className="text-foreground py-4">Unlimited Teams</p>
<p className="text-foreground py-4">Unlimited Users</p>
<p className="text-foreground py-4">Unlimited Documents per month</p>
<p className="text-foreground py-4">Includes all upcoming features</p>
<p className="text-foreground py-4">Join the movement</p>
<p className="text-foreground py-4">Simple signing solution</p>
<p className="text-foreground py-4">Email, Discord and Slack assistance</p>
<p className="text-foreground py-4">
<strong>
{' '}
<a href="https://documenso.com/blog/early-adopters" target="_blank">
Includes all upcoming features
</a>
</strong>
</p>
<p className="text-foreground py-4">Fixed, straightforward pricing</p>
</div>
<div className="flex-1" />
</div>
<div

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
"@documenso/ee": "*",
"@documenso/lib": "*",
@@ -43,7 +42,6 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"remeda": "^1.27.1",
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",

View File

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

View File

@@ -1,11 +1,11 @@
'use client';
import type { HTMLAttributes } from 'react';
import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -78,20 +78,6 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
Subscriptions
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/banner') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/site-settings">
<Settings className="mr-2 h-5 w-5" />
Site Settings
</Link>
</Button>
</div>
);
};

View File

@@ -1,200 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import type { TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export type BannerFormProps = {
banner?: TSiteSettingsBannerSchema;
};
export function BannerForm({ banner }: BannerFormProps) {
const router = useRouter();
const { toast } = useToast();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isLoading: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: 'Banner Updated',
description: 'Your banner has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update the banner. Please try again later.',
});
}
}
};
return (
<div>
<h2 className="font-semibold">Site Banner</h2>
<p className="text-muted-foreground mt-2 text-sm">
The site banner is a message that is shown at the top of the site. It can be used to display
important information to your users.
</p>
<Form {...form}>
<form
className="mt-4 flex flex-col rounded-md"
onSubmit={form.handleSubmit(onBannerUpdate)}
>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Enabled</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset
className="flex flex-col gap-4 md:flex-row"
disabled={!enabled}
aria-disabled={!enabled}
>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>Background Color</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>Text Color</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
The content to show in the banner, HTML is allowed
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="submit"
loading={isUpdateSiteSettingLoading}
className="mt-4 justify-end self-end"
>
Update Banner
</Button>
</form>
</Form>
</div>
);
}

View File

@@ -1,24 +0,0 @@
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { BannerForm } from './banner-form';
// import { BannerForm } from './banner-form';
export default async function AdminBannerPage() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return (
<div>
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
<div className="mt-8">
<BannerForm banner={banner} />
</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
'use client';
import Link from 'next/link';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewButtonProps = {
document: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
if (!session) {
return null;
}
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
const isRecipient = !!recipient;
const isPending = document.status === DocumentStatus.PENDING;
const isComplete = document.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const documentsPath = formatDocumentsPath(document.team?.url);
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query({
id: document.id,
teamId: team?.id,
});
const documentData = documentWithData?.documentData;
if (!documentData) {
throw new Error('No document available');
}
await downloadPDF({ documentData, fileName: documentWithData.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
}
};
return match({
isRecipient,
isPending,
isComplete,
isSigned,
})
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-full" asChild>
<Link href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</>
))}
</Link>
</Button>
))
.with({ isComplete: false }, () => (
<Button className="w-full" asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>Edit</Link>
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
</Button>
))
.otherwise(() => null);
};

View File

@@ -1,160 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ResendDocumentActionItem } from '../_action-items/resend-document';
import { DeleteDocumentDialog } from '../delete-document-dialog';
import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
export type DocumentPageViewDropdownProps = {
document: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
if (!session) {
return null;
}
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = document.User.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT;
const isComplete = document.status === DocumentStatus.COMPLETED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const documentsPath = formatDocumentsPath(team?.url);
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query({
id: document.id,
teamId: team?.id,
});
const documentData = documentWithData?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: document.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
}
};
const nonSignedRecipients = document.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
{(isOwner || isCurrentTeamDocument) && !isComplete && (
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
)}
{isComplete && (
<DropdownMenuItem onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<ResendDocumentActionItem
document={document}
recipients={nonSignedRecipients}
team={team}
/>
<DocumentShareButton
documentId={document.id}
token={isOwner ? undefined : recipient?.token}
trigger={({ loading, disabled }) => (
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share Signing Card
</div>
</DropdownMenuItem>
)}
/>
</DropdownMenuContent>
{isDocumentDeletable && (
<DeleteDocumentDialog
id={document.id}
status={document.status}
documentTitle={document.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
)}
{isDuplicateDialogOpen && (
<DuplicateDocumentDialog
id={document.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
team={team}
/>
)}
</DropdownMenu>
);
};

View File

@@ -1,72 +0,0 @@
'use client';
import { useMemo } from 'react';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useLocale } from '@documenso/lib/client-only/providers/locale';
import type { Document, Recipient, User } from '@documenso/prisma/client';
export type DocumentPageViewInformationProps = {
userId: number;
document: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
};
};
export const DocumentPageViewInformation = ({
document,
userId,
}: DocumentPageViewInformationProps) => {
const isMounted = useIsMounted();
const { locale } = useLocale();
const documentInformation = useMemo(() => {
let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative();
if (!isMounted) {
createdValue = DateTime.fromJSDate(document.createdAt)
.setLocale(locale)
.toFormat('MMMM d, yyyy');
lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative();
}
return [
{
description: 'Uploaded by',
value: userId === document.userId ? 'You' : document.User.name ?? document.User.email,
},
{
description: 'Created',
value: createdValue,
},
{
description: 'Last modified',
value: lastModifiedValue,
},
];
}, [isMounted, document, locale, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<h1 className="px-4 py-3 font-medium">Information</h1>
<ul className="divide-y border-t">
{documentInformation.map((item) => (
<li
key={item.description}
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
>
<span className="text-muted-foreground">{item.description}</span>
<span>{item.value}</span>
</li>
))}
</ul>
</section>
);
};

View File

@@ -1,153 +0,0 @@
'use client';
import { useMemo } from 'react';
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
export type DocumentPageViewRecentActivityProps = {
documentId: number;
userId: number;
};
export const DocumentPageViewRecentActivity = ({
documentId,
userId,
}: DocumentPageViewRecentActivityProps) => {
const {
data,
isLoading,
isLoadingError,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
{
documentId,
filterForRecentActivity: true,
orderBy: {
column: 'createdAt',
direction: 'asc',
},
perPage: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">Recent activity</h1>
{/* Can add dropdown menu here for additional options. */}
</div>
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">Unable to load document history</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
Click here to retry
</button>
</div>
)}
<AnimateGenericFadeInOut>
{data && (
<ul role="list" className="space-y-6 p-4">
{hasNextPage && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="bg-border w-px" />
</div>
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={async () => fetchNextPage()}
className="text-foreground/70 hover:text-muted-foreground text-xs"
>
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
</button>
</li>
)}
{documentAuditLogs.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-muted-foreground/70 text-sm">No recent activity</p>
</div>
)}
{documentAuditLogs.map((auditLog, auditLogIndex) => (
<li key={auditLog.id} className="relative flex gap-x-4">
<div
className={cn(
auditLogIndex === documentAuditLogs.length - 1 ? 'h-6' : '-bottom-6',
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="bg-border w-px" />
</div>
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
{match(auditLog.type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<CheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<MailOpen className="h-3 w-3" aria-hidden="true" />
</div>
))
.otherwise(() => (
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
))}
</div>
<p className="text-muted-foreground dark:text-muted-foreground/70 flex-auto py-0.5 text-xs leading-5">
<span className="text-foreground font-medium">
{formatDocumentAuditLogAction(auditLog, userId).prefix}
</span>{' '}
{formatDocumentAuditLogAction(auditLog, userId).description}
</p>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
</time>
</li>
))}
</ul>
)}
</AnimateGenericFadeInOut>
</section>
);
};

View File

@@ -1,115 +0,0 @@
import Link from 'next/link';
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } from '@documenso/prisma/client';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
export type DocumentPageViewRecipientsProps = {
document: Document & {
Recipient: Recipient[];
};
documentRootPath: string;
};
export const DocumentPageViewRecipients = ({
document,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const recipients = document.Recipient;
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">Recipients</h1>
{document.status !== DocumentStatus.COMPLETED && (
<Link
href={`${documentRootPath}/${document.id}/edit?step=signers`}
title="Modify recipients"
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
)}
{recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
}
/>
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.SIGNED && (
<Badge variant="default">
{match(recipient.role)
.with(RecipientRole.APPROVER, () => (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Approved
</>
))
.with(RecipientRole.CC, () =>
document.status === DocumentStatus.COMPLETED ? (
<>
<MailIcon className="mr-1 h-3 w-3" />
Sent
</>
) : (
<>
<CheckIcon className="mr-1 h-3 w-3" />
Ready
</>
),
)
.with(RecipientRole.SIGNER, () => (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
Signed
</>
))
.with(RecipientRole.VIEWER, () => (
<>
<MailOpenIcon className="mr-1 h-3 w-3" />
Viewed
</>
))
.exhaustive()}
</Badge>
)}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
Pending
</Badge>
)}
</li>
))}
</ul>
</section>
);
};

View File

@@ -1,34 +1,22 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { match } from 'ts-pattern';
import { ChevronLeft, Users2 } from 'lucide-react';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Team } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
} from '~/components/formatter/document-status';
import { DocumentPageViewButton } from './document-page-view-button';
import { DocumentPageViewDropdown } from './document-page-view-dropdown';
import { DocumentPageViewInformation } from './document-page-view-information';
import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity';
import { DocumentPageViewRecipients } from './document-page-view-recipients';
import { DocumentStatus } from '~/components/formatter/document-status';
export type DocumentPageViewProps = {
params: {
@@ -37,7 +25,7 @@ export type DocumentPageViewProps = {
team?: Team;
};
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
export default async function DocumentPageView({ params, team }: DocumentPageViewProps) {
const { id } = params;
const documentId = Number(id);
@@ -56,10 +44,6 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
teamId: team?.id,
}).catch(() => null);
const isDocumentHistoryEnabled = await getServerComponentFlag(
'app_document_page_view_history_sheet',
);
if (!document || !document.documentData) {
redirect(documentRootPath);
}
@@ -83,16 +67,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentMeta.password = securePassword;
}
const recipients = await getRecipientsForDocument({
documentId,
teamId: team?.id,
userId: user.id,
});
const documentWithRecipients = {
...document,
Recipient: recipients,
};
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
userId: user.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
@@ -101,105 +85,47 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
Documents
</Link>
<div className="flex flex-row justify-between">
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
status={document.status}
className="text-muted-foreground"
/>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
</div>
{isDocumentHistoryEnabled && (
<div className="self-end">
<DocumentHistorySheet documentId={document.id} userId={user.id}>
<Button variant="outline">
<Clock9 className="mr-1.5 h-4 w-4" />
Document history
</Button>
</DocumentHistorySheet>
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
<div className="mt-6 grid w-full grid-cols-12 gap-8">
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
</CardContent>
</Card>
{document.status !== InternalDocumentStatus.COMPLETED && (
<EditDocumentForm
className="mt-8"
document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath}
/>
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
</h3>
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm ">
{match(document.status)
.with(
DocumentStatus.COMPLETED,
() => 'This document has been signed by all recipients',
)
.with(
DocumentStatus.DRAFT,
() => 'This document is currently a draft and has not been sent',
)
.with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
);
return `Waiting on ${pendingRecipients.length} recipient${
pendingRecipients.length > 1 ? 's' : ''
}`;
})
.exhaustive()}
</p>
<div className="mt-4 border-t px-4 pt-4">
<DocumentPageViewButton document={documentWithRecipients} team={team} />
</div>
</section>
{/* Document information section. */}
<DocumentPageViewInformation document={documentWithRecipients} userId={user.id} />
{/* Recipients section. */}
<DocumentPageViewRecipients
document={documentWithRecipients}
documentRootPath={documentRootPath}
/>
{/* Recent activity section. */}
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
</div>
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer
document={document}
key={documentData.id}
documentMeta={documentMeta}
documentData={documentData}
/>
</div>
</div>
)}
</div>
);
};
}

View File

@@ -2,16 +2,10 @@
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import {
type DocumentData,
type DocumentMeta,
DocumentStatus,
type Field,
type Recipient,
type User,
} from '@documenso/prisma/client';
import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -30,8 +24,6 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type EditDocumentFormProps = {
className?: string;
user: User;
@@ -57,10 +49,12 @@ export const EditDocumentForm = ({
documentRootPath,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const team = useOptionalCurrentTeam();
// controlled stepper state
const [step, setStep] = useState<EditDocumentStep>(
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
);
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
@@ -92,30 +86,11 @@ export const EditDocumentForm = ({
},
};
const [step, setStep] = useState<EditDocumentStep>(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
let initialStep: EditDocumentStep =
document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
if (
searchParamStep &&
documentFlow[searchParamStep] !== undefined &&
!(recipients.length === 0 && (searchParamStep === 'subject' || searchParamStep === 'fields'))
) {
initialStep = searchParamStep;
}
return initialStep;
});
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
try {
// Custom invocation server action
await addTitle({
documentId: document.id,
teamId: team?.id,
title: data.title,
});
@@ -138,7 +113,6 @@ export const EditDocumentForm = ({
// Custom invocation server action
await addSigners({
documentId: document.id,
teamId: team?.id,
signers: data.signers,
});
@@ -177,18 +151,16 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
const { subject, message, timezone, dateFormat } = data.meta;
try {
await sendDocument({
documentId: document.id,
teamId: team?.id,
meta: {
subject,
message,
dateFormat,
timezone,
redirectUrl,
dateFormat,
},
});

View File

@@ -1,122 +0,0 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
export type DocumentEditPageViewProps = {
params: {
id: string;
};
team?: Team;
};
export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => {
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
redirect(documentRootPath);
}
const { user } = await getRequiredServerComponentSession();
const document = await getDocumentById({
id: documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!document || !document.documentData) {
redirect(documentRootPath);
}
if (document.status === InternalDocumentStatus.COMPLETED) {
redirect(`${documentRootPath}/${documentId}`);
}
const { documentData, documentMeta } = document;
if (documentMeta?.password) {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const securePassword = Buffer.from(
symmetricDecrypt({
key,
data: documentMeta.password,
}),
).toString('utf-8');
documentMeta.password = securePassword;
}
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
<EditDocumentForm
className="mt-8"
document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath}
/>
</div>
);
};

View File

@@ -1,11 +0,0 @@
import { DocumentEditPageView } from './document-edit-page-view';
export type DocumentPageProps = {
params: {
id: string;
};
};
export default function DocumentEditPage({ params }: DocumentPageProps) {
return <DocumentEditPageView params={params} />;
}

View File

@@ -1,165 +0,0 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { trpc } from '@documenso/trpc/react';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { LocaleDate } from '~/components/formatter/locale-date';
export type DocumentLogsDataTableProps = {
documentId: number;
};
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
const parser = new UAParser();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isInitialLoading, isLoadingError } =
trpc.document.findDocumentAuditLogs.useQuery(
{
documentId,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
keepPreviousData: true,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const uppercaseFistLetter = (text: string) => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<DataTable
columns={[
{
header: 'Time',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'User',
accessorKey: 'name',
cell: ({ row }) =>
row.original.name || row.original.email ? (
<div>
{row.original.name && (
<p className="truncate" title={row.original.name}>
{row.original.name}
</p>
)}
{row.original.email && (
<p className="truncate" title={row.original.email}>
{row.original.email}
</p>
)}
</div>
) : (
<p>N/A</p>
),
},
{
header: 'Action',
accessorKey: 'type',
cell: ({ row }) => (
<span>
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
</span>
),
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading && isInitialLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell className="w-1/2 py-4 pr-4">
<div className="ml-2 flex flex-grow flex-col">
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-10 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
);
};

View File

@@ -1,151 +0,0 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, DownloadIcon } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Recipient, Team } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Card } from '@documenso/ui/primitives/card';
import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status';
import { DocumentLogsDataTable } from './document-logs-data-table';
export type DocumentLogsPageViewProps = {
params: {
id: string;
};
team?: Team;
};
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
redirect(documentRootPath);
}
const { user } = await getRequiredServerComponentSession();
const [document, recipients] = await Promise.all([
getDocumentById({
id: documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null),
getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
}),
]);
if (!document || !document.documentData) {
redirect(documentRootPath);
}
const documentInformation: { description: string; value: string }[] = [
{
description: 'Document title',
value: document.title,
},
{
description: 'Document ID',
value: document.id.toString(),
},
{
description: 'Document status',
value: FRIENDLY_STATUS_MAP[document.status].label,
},
{
description: 'Created by',
value: document.User.name ?? document.User.email,
},
{
description: 'Date created',
value: document.createdAt.toISOString(),
},
{
description: 'Last updated',
value: document.updatedAt.toISOString(),
},
{
description: 'Time zone',
value: document.documentMeta?.timezone ?? 'N/A',
},
];
const formatRecipientText = (recipient: Recipient) => {
let text = recipient.email;
if (recipient.name) {
text = `${recipient.name} (${recipient.email})`;
}
return `${text} - ${recipient.role}`;
};
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link
href={`${documentRootPath}/${document.id}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Document
</Link>
<div className="flex flex-col justify-between sm:flex-row">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<Button variant="outline" className="mr-2 w-full sm:w-auto">
<DownloadIcon className="mr-1.5 h-4 w-4" />
Download certificate
</Button>
<Button className="w-full sm:w-auto">
<DownloadIcon className="mr-1.5 h-4 w-4" />
Download PDF
</Button>
</div>
</div>
<section className="mt-6">
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
{documentInformation.map((info, i) => (
<div className="text-foreground text-sm" key={i}>
<h3 className="font-semibold">{info.description}</h3>
<p className="text-muted-foreground">{info.value}</p>
</div>
))}
<div className="text-foreground text-sm">
<h3 className="font-semibold">Recipients</h3>
<ul className="text-muted-foreground list-inside list-disc">
{recipients.map((recipient) => (
<li key={`recipient-${recipient.id}`}>
<span className="-ml-2">{formatRecipientText(recipient)}</span>
</li>
))}
</ul>
</div>
</Card>
</section>
<section className="mt-6">
<DocumentLogsDataTable documentId={document.id} />
</section>
</div>
);
};

View File

@@ -1,11 +0,0 @@
import { DocumentLogsPageView } from './document-logs-page-view';
export type DocumentsLogsPageProps = {
params: {
id: string;
};
};
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
return <DocumentLogsPageView params={params} />;
}

View File

@@ -1,4 +1,4 @@
import { DocumentPageView } from './document-page-view';
import DocumentPageView from './document-page-view';
export type DocumentPageProps = {
params: {

View File

@@ -108,86 +108,88 @@ export const ResendDocumentActionItem = ({
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
</DialogTrigger>
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle asChild>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
>
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
Cancel
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
<Button className="w-32" asChild>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Link href={`${documentsPath}/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>

View File

@@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
{recipient && recipient?.role !== RecipientRole.CC && (
{recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
@@ -142,7 +142,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
)}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}/edit`}>
<Link href={`${documentsPath}/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
@@ -193,7 +193,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
teamId={team?.id}
/>
)}
{isDuplicateDialogOpen && (

View File

@@ -5,19 +5,16 @@ import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { Document, Recipient, User } from '@documenso/prisma/client';
export type DataTableTitleProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'url'> | null;
Recipient: Recipient[];
};
teamUrl?: string;
};
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const { data: session } = useSession();
if (!session) {
@@ -28,18 +25,14 @@ export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const isOwner = row.User.id === session.user.id;
const isRecipient = !!recipient;
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
return match({
isOwner,
isRecipient,
isCurrentTeamDocument,
})
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
.with({ isOwner: true }, () => (
<Link
href={`${documentsPath}/${row.id}`}
href={`/documents/${row.id}`}
title={row.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>

View File

@@ -66,7 +66,7 @@ export const DocumentsDataTable = ({
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
id: 'sender',

View File

@@ -16,13 +16,12 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteDocumentDialogProps = {
type DeleteDraftDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
documentTitle: string;
teamId?: number;
};
export const DeleteDocumentDialog = ({
@@ -31,8 +30,7 @@ export const DeleteDocumentDialog = ({
onOpenChange,
status,
documentTitle,
teamId,
}: DeleteDocumentDialogProps) => {
}: DeleteDraftDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
@@ -63,7 +61,7 @@ export const DeleteDocumentDialog = ({
const onDelete = async () => {
try {
await deleteDocument({ id, teamId });
await deleteDocument({ id, status });
} catch {
toast({
title: 'Something went wrong',

View File

@@ -33,7 +33,10 @@ export type DocumentsPageViewProps = {
team?: Team & { teamEmail?: TeamEmail | null };
};
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
export default async function DocumentsPageView({
searchParams = {},
team,
}: DocumentsPageViewProps) {
const { user } = await getRequiredServerComponentSession();
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
@@ -152,4 +155,4 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
</div>
</div>
);
};
}

View File

@@ -47,7 +47,7 @@ export const DuplicateDocumentDialog = ({
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
router.push(`${documentsPath}/${newId}/edit`);
router.push(`${documentsPath}/${newId}`);
toast({
title: 'Document Duplicated',

View File

@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import type { DocumentsPageViewProps } from './documents-page-view';
import { DocumentsPageView } from './documents-page-view';
import DocumentsPageView from './documents-page-view';
export type DocumentsPageProps = {
searchParams?: DocumentsPageViewProps['searchParams'];

View File

@@ -83,7 +83,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
} catch (error) {
console.error(error);
@@ -117,7 +117,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
return (
<div className={cn('relative', className)}>
<DocumentDropzone
className="h-[min(400px,50vh)]"
className="min-h-[40vh]"
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}

View File

@@ -9,7 +9,6 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Banner } from '~/components/(dashboard)/layout/banner';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
@@ -38,8 +37,6 @@ export default async function AuthenticatedDashboardLayout({
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Banner />
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@@ -2,7 +2,6 @@
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
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';
export const createBillingPortal = async () => {
@@ -12,6 +11,6 @@ export const createBillingPortal = async () => {
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
};

View File

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

View File

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

View File

@@ -1,124 +0,0 @@
'use client';
import { signOut } from 'next-auth/react';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteAccountDialogProps = {
className?: string;
user: User;
};
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
const { toast } = useToast();
const hasTwoFactorAuthentication = user.twoFactorEnabled;
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation();
const onDeleteAccount = async () => {
try {
await deleteAccount();
toast({
title: 'Account deleted',
description: 'Your account has been deleted successfully.',
duration: 5000,
});
return await signOut({ callbackUrl: '/' });
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
err.message ??
'We encountered an unknown error while attempting to delete your account. Please try again later.',
});
}
}
};
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
variant="neutral"
>
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertDescription className="mr-2">
Delete your account and all its contents, including completed documents. This action is
irreversible and will cancel your subscription, so proceed with caution.
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
</AlertDescription>
</Alert>
{hasTwoFactorAuthentication && (
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
Disable Two Factor Authentication before deleting your account.
</AlertDescription>
</Alert>
)}
<DialogDescription>
Documenso will delete <span className="font-semibold">all of your documents</span>
, along with all of your completed documents, signatures, and all other resources
belonging to your Account.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={onDeleteAccount}
loading={isDeletingAccount}
variant="destructive"
disabled={hasTwoFactorAuthentication}
>
{isDeletingAccount ? 'Deleting account...' : 'Delete Account'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};

View File

@@ -5,8 +5,6 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { ProfileForm } from '~/components/forms/profile';
import { DeleteAccountDialog } from './delete-account-dialog';
export const metadata: Metadata = {
title: 'Profile',
};
@@ -18,9 +16,7 @@ export default async function ProfileSettingsPage() {
<div>
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<ProfileForm className="max-w-xl" user={user} />
<DeleteAccountDialog className="mt-8 max-w-xl" user={user} />
<ProfileForm user={user} className="max-w-xl" />
</div>
);
}

View File

@@ -1,83 +0,0 @@
import { DateTime } from 'luxon';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
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. <br />
You can view our swagger docs{' '}
<a
className="text-primary underline"
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
target="_blank"
>
here
</a>
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">Your existing tokens</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
Your tokens will be shown here once you create them.
</p>
</div>
)}
{tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Token doesn't have an expiration date
</p>
)}
</div>
<div>
<DeleteTokenDialog token={token}>
<Button variant="destructive">Delete</Button>
</DeleteTokenDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,201 +0,0 @@
'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,
FormDescription,
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 { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export type WebhookPageOptions = {
params: {
id: string;
};
};
export default function WebhookPage({ params }: WebhookPageOptions) {
const { toast } = useToast();
const router = useRouter();
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
{
id: 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: 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 max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
The URL for Documenso to send webhook events to.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>Enabled</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>Triggers</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
The events that will trigger a webhook to be sent to your URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update webhook
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@@ -1,101 +0,0 @@
'use client';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
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';
import { LocaleDate } from '~/components/formatter/locale-date';
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={cn(
'border-border rounded-lg border p-4',
!webhook.enabled && 'bg-muted/40',
)}
>
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="truncate font-mono text-xs">{webhook.id}</div>
<div className="mt-1.5 flex items-center gap-4">
<h5
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
title={webhook.webhookUrl}
>
{webhook.webhookUrl}
</h5>
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
{webhook.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</p>
<p className="text-muted-foreground mt-2 text-xs">
Created on{' '}
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
</p>
</div>
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-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>
)}
</div>
);
}

View File

@@ -28,7 +28,6 @@ export type EditTemplateFormProps = {
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
templateRootPath: string;
};
type EditTemplateStep = 'signers' | 'fields';
@@ -41,7 +40,6 @@ export const EditTemplateForm = ({
fields,
user: _user,
documentData,
templateRootPath,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
@@ -100,7 +98,7 @@ export const EditTemplateForm = ({
duration: 5000,
});
router.push(templateRootPath);
router.push('/templates');
} catch (err) {
toast({
title: 'Error',

View File

@@ -1,10 +1,81 @@
import React from 'react';
import type { TemplatePageViewProps } from './template-page-view';
import { TemplatePageView } from './template-page-view';
import Link from 'next/link';
import { redirect } from 'next/navigation';
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
import { ChevronLeft } from 'lucide-react';
export default function TemplatePage({ params }: TemplatePageProps) {
return <TemplatePageView params={params} />;
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { TemplateType } from '~/components/formatter/template-type';
import { EditTemplateForm } from './edit-template';
export type TemplatePageProps = {
params: {
id: string;
};
};
export default async function TemplatePage({ params }: TemplatePageProps) {
const { id } = params;
const templateId = Number(id);
if (!templateId || Number.isNaN(templateId)) {
redirect('/documents');
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect('/documents');
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
/>
</div>
);
}

View File

@@ -1,86 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { TemplateType } from '~/components/formatter/template-type';
import { EditTemplateForm } from './edit-template';
export type TemplatePageViewProps = {
params: {
id: string;
};
team?: Team;
};
export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) => {
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
redirect(templateRootPath);
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect(templateRootPath);
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
templateRootPath={templateRootPath}
/>
</div>
);
};

View File

@@ -21,15 +21,9 @@ import { DuplicateTemplateDialog } from './duplicate-template-dialog';
export type DataTableActionDropdownProps = {
row: Template;
templateRootPath: string;
teamId?: number;
};
export const DataTableActionDropdown = ({
row,
templateRootPath,
teamId,
}: DataTableActionDropdownProps) => {
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -40,7 +34,6 @@ export const DataTableActionDropdown = ({
}
const isOwner = row.userId === session.user.id;
const isTeamTemplate = row.teamId === teamId;
return (
<DropdownMenu>
@@ -51,25 +44,20 @@ export const DataTableActionDropdown = ({
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link href={`${templateRootPath}/${row.id}`}>
<DropdownMenuItem disabled={!isOwner} asChild>
<Link href={`/templates/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDuplicateDialogOpen(true)}
>
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
>
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
@@ -77,7 +65,6 @@ export const DataTableActionDropdown = ({
<DuplicateTemplateDialog
id={row.id}
teamId={teamId}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>

View File

@@ -1,37 +1,33 @@
'use client';
import { useTransition } from 'react';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Loader } from 'lucide-react';
import { AlertTriangle, Loader, Plus } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Recipient, Template } from '@documenso/prisma/client';
import type { Template } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { LocaleDate } from '~/components/formatter/locale-date';
import { TemplateType } from '~/components/formatter/template-type';
import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
import { UseTemplateDialog } from './use-template-dialog';
type TemplateWithRecipient = Template & {
Recipient: Recipient[];
};
type TemplatesDataTableProps = {
templates: TemplateWithRecipient[];
templates: Template[];
perPage: number;
page: number;
totalPages: number;
documentRootPath: string;
templateRootPath: string;
teamId?: number;
};
export const TemplatesDataTable = ({
@@ -39,15 +35,20 @@ export const TemplatesDataTable = ({
perPage,
page,
totalPages,
documentRootPath,
templateRootPath,
teamId,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const { remaining } = useLimits();
const router = useRouter();
const { toast } = useToast();
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
@@ -57,6 +58,28 @@ export const TemplatesDataTable = ({
});
};
const onUseButtonClick = async (templateId: number) => {
try {
const { id } = await createDocumentFromTemplate({
templateId,
});
toast({
title: 'Document created',
description: 'Your document has been created from the template successfully.',
duration: 5000,
});
router.push(`/documents/${id}`);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
}
};
return (
<div className="relative">
{remaining.documents === 0 && (
@@ -92,19 +115,23 @@ export const TemplatesDataTable = ({
header: 'Actions',
accessorKey: 'actions',
cell: ({ row }) => {
const isRowLoading = loadingStates[row.original.id];
return (
<div className="flex items-center gap-x-4">
<UseTemplateDialog
templateId={row.original.id}
recipients={row.original.Recipient}
documentRootPath={documentRootPath}
/>
<DataTableActionDropdown
row={row.original}
teamId={teamId}
templateRootPath={templateRootPath}
/>
<Button
disabled={isRowLoading || remaining.documents === 0}
loading={isRowLoading}
onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
await onUseButtonClick(row.original.id);
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
}}
>
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<DataTableActionDropdown row={row.original} />
</div>
);
},

View File

@@ -35,15 +35,20 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
onOpenChange(false);
},
onError: () => {
});
const onDeleteTemplate = async () => {
try {
await deleteTemplate({ id });
} catch {
toast({
title: 'Something went wrong',
description: 'This template could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
},
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
@@ -58,18 +63,20 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
Delete
</Button>
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -14,14 +14,12 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
id,
teamId,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
@@ -42,15 +40,22 @@ export const DuplicateTemplateDialog = ({
onOpenChange(false);
},
onError: () => {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
},
});
const onDuplicate = async () => {
try {
await duplicateTemplate({
templateId: id,
});
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
@@ -61,27 +66,20 @@ export const DuplicateTemplateDialog = ({
</DialogHeader>
<DialogFooter>
<Button
type="button"
disabled={isLoading}
variant="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
loading={isLoading}
onClick={async () =>
duplicateTemplate({
templateId: id,
teamId,
})
}
>
Duplicate
</Button>
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
Duplicate
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -43,14 +43,8 @@ const ZCreateTemplateFormSchema = z.object({
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
type NewTemplateDialogProps = {
teamId?: number;
templateRootPath: string;
};
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
export const NewTemplateDialog = () => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
@@ -105,7 +99,6 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
});
const { id } = await createTemplate({
teamId,
title: values.name ? values.name : file.name,
templateDocumentDataId,
});
@@ -119,7 +112,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
setShowNewTemplateDialog(false);
router.push(`${templateRootPath}/${id}`);
void router.push(`/templates/${id}`);
} catch {
toast({
title: 'Something went wrong',

View File

@@ -2,17 +2,57 @@ import React from 'react';
import type { Metadata } from 'next';
import { TemplatesPageView } from './templates-page-view';
import type { TemplatesPageViewProps } from './templates-page-view';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
import { NewTemplateDialog } from './new-template-dialog';
type TemplatesPageProps = {
searchParams?: TemplatesPageViewProps['searchParams'];
searchParams?: {
page?: number;
perPage?: number;
};
};
export const metadata: Metadata = {
title: 'Templates',
};
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
return <TemplatesPageView searchParams={searchParams} />;
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const { templates, totalPages } = await getTemplates({
userId: user.id,
page: page,
perPage: perPage,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
<div>
<NewTemplateDialog />
</div>
</div>
<div className="relative">
{templates.length > 0 ? (
<TemplatesDataTable
templates={templates}
page={page}
perPage={perPage}
totalPages={totalPages}
/>
) : (
<EmptyTemplateState />
)}
</div>
</div>
);
}

View File

@@ -1,73 +0,0 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
import { NewTemplateDialog } from './new-template-dialog';
export type TemplatesPageViewProps = {
searchParams?: {
page?: number;
perPage?: number;
};
team?: Team;
};
export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPageViewProps) => {
const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const documentRootPath = formatDocumentsPath(team?.url);
const templateRootPath = formatTemplatesPath(team?.url);
const { templates, totalPages } = await findTemplates({
userId: user.id,
teamId: team?.id,
page: page,
perPage: perPage,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="truncate text-2xl font-semibold md:text-3xl">Templates</h1>
</div>
<div>
<NewTemplateDialog templateRootPath={templateRootPath} teamId={team?.id} />
</div>
</div>
<div className="relative mt-5">
{templates.length > 0 ? (
<TemplatesDataTable
templates={templates}
page={page}
perPage={perPage}
totalPages={totalPages}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
teamId={team?.id}
/>
) : (
<EmptyTemplateState />
)}
</div>
</div>
);
};

View File

@@ -1,247 +0,0 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type UseTemplateDialogProps = {
templateId: number;
recipients: Recipient[];
documentRootPath: string;
};
export function UseTemplateDialog({
recipients,
documentRootPath,
templateId,
}: UseTemplateDialogProps) {
const router = useRouter();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
recipients:
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}))
: [
{
name: '',
email: '',
role: RecipientRole.SIGNER,
},
],
},
});
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
const { id } = await createDocumentFromTemplate({
templateId,
teamId: team?.id,
recipients: data.recipients,
});
toast({
title: 'Document created',
description: 'Your document has been created from the template successfully.',
duration: 5000,
});
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
}
};
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({
control,
name: 'recipients',
});
return (
<Dialog>
<DialogTrigger asChild>
<Button className="cursor-pointer">
<Plus className="-ml-1 mr-2 h-4 w-4" />
Use Template
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Document Recipients</DialogTitle>
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-4">
{formRecipients.map((recipient, index) => (
<div
key={recipient.id}
data-native-id={recipient.id}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller
control={control}
name={`recipients.${index}.email`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
/>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
<Controller
control={control}
name={`recipients.${index}.name`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
/>
)}
/>
</div>
<div className="w-[60px]">
<Controller
control={control}
name={`recipients.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
</div>
</div>
))}
</div>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
loading={isCreatingDocumentFromTemplate}
disabled={isCreatingDocumentFromTemplate}
onClick={onCreateDocumentFromTemplate}
>
Create Document
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

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

View File

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

View File

@@ -26,10 +26,9 @@ export type SigningFormProps = {
document: Document;
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
};
export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
@@ -75,7 +74,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
timestamp: new Date().toISOString(),
});
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
router.push(`/sign/${recipient.token}/complete`);
};
return (

View File

@@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div>
<div className="py-4">
<Label htmlFor="signature">Full Name</Label>
<Input

View File

@@ -1,4 +1,3 @@
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
@@ -9,12 +8,12 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@@ -29,7 +28,6 @@ import { NameField } from './name-field';
import { NoLongerAvailable } from './no-longer-available';
import { SigningProvider } from './provider';
import { SignatureField } from './signature-field';
import { TextField } from './text-field';
export type SigningPageProps = {
params: {
@@ -42,26 +40,24 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const requestHeaders = Object.fromEntries(headers().entries());
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token, requestMetadata }).catch(() => null),
viewedDocument({ token }).catch(() => null),
]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData, documentMeta } = document;
const { documentData } = document;
const { user } = await getServerComponentSession();
@@ -69,9 +65,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED
) {
documentMeta?.redirectUrl
? redirect(documentMeta.redirectUrl)
: redirect(`/sign/${token}/complete`);
redirect(`/sign/${token}/complete`);
}
if (documentMeta?.password) {
@@ -139,12 +133,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
<SigningForm document={document} recipient={recipient} fields={fields} />
</div>
</div>
@@ -169,9 +158,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => (
<TextField key={field.id} field={field} recipient={recipient} />
))
.otherwise(() => null),
)}
</ElementVisible>

View File

@@ -1,166 +0,0 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningFieldContainer } from './signing-field-container';
export type TextFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
};
export const TextField = ({ field, recipient }: TextFieldProps) => {
const router = useRouter();
const { toast } = useToast();
const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation();
const {
mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState('');
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
useEffect(() => {
if (!showCustomTextModal && !isLocalSignatureSet) {
setLocalCustomText('');
}
}, [showCustomTextModal, isLocalSignatureSet]);
const onSign = async () => {
try {
if (!localText) {
setIsLocalSignatureSet(false);
setShowCustomTextModal(true);
return;
}
if (!localText) {
return;
}
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: localText,
isBase64: true,
});
setLocalCustomText('');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while signing the document.',
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
});
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while removing the text.',
variant: 'destructive',
});
}
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Text</p>
)}
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
<DialogContent>
<DialogTitle>
Enter your Text <span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="">
<Label htmlFor="custom-text">Custom Text</Label>
<Input
id="custom-text"
className="border-border mt-2 w-full rounded-md border"
onChange={(e) => setLocalCustomText(e.target.value)}
/>
</div>
<DialogFooter>
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowCustomTextModal(false);
setLocalCustomText('');
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!localText}
onClick={() => {
setShowCustomTextModal(false);
setIsLocalSignatureSet(true);
void onSign();
}}
>
Save Text
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};

View File

@@ -1,21 +0,0 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentEditPageView } from '~/app/(dashboard)/documents/[id]/edit/document-edit-page-view';
export type DocumentPageProps = {
params: {
id: string;
teamUrl: string;
};
};
export default async function TeamsDocumentEditPage({ params }: DocumentPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentEditPageView params={params} team={team} />;
}

View File

@@ -1,20 +0,0 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentLogsPageView } from '~/app/(dashboard)/documents/[id]/logs/document-logs-page-view';
export type TeamDocumentsLogsPageProps = {
params: {
id: string;
teamUrl: string;
};
};
export default async function TeamsDocumentsLogsPage({ params }: TeamDocumentsLogsPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentLogsPageView params={params} team={team} />;
}

View File

@@ -1,7 +1,7 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentPageView } from '~/app/(dashboard)/documents/[id]/document-page-view';
import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-view';
export type DocumentPageProps = {
params: {
@@ -16,5 +16,5 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentPageView params={params} team={team} />;
return <DocumentPageComponent params={params} team={team} />;
}

View File

@@ -2,7 +2,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view';
import { DocumentsPageView } from '~/app/(dashboard)/documents/documents-page-view';
import DocumentsPageView from '~/app/(dashboard)/documents/documents-page-view';
export type TeamsDocumentPageProps = {
params: {

View File

@@ -11,7 +11,6 @@ import { SubscriptionStatus } from '@documenso/prisma/client';
import { Header } from '~/components/(dashboard)/layout/header';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
import { TeamProvider } from '~/providers/team';
import { LayoutBillingBanner } from './layout-billing-banner';
@@ -57,9 +56,7 @@ export default async function AuthenticatedTeamsLayout({
<Header user={user} teams={teams} />
<TeamProvider team={team}>
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
</TeamProvider>
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<RefreshOnFocus />
</LimitsProvider>

View File

@@ -18,7 +18,7 @@ export type TeamTransferStatusProps = {
className?: string;
currentUserTeamRole: TeamMemberRole;
teamId: number;
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
transferVerification: TeamTransferVerification | null;
};
export const TeamTransferStatus = ({

View File

@@ -1,94 +0,0 @@
import { DateTime } from 'luxon';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
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';
type ApiTokensPageProps = {
params: {
teamUrl: string;
};
};
export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const tokens = await getTeamTokens({ userId: user.id, teamId: team.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. <br />
You can view our swagger docs{' '}
<a
className="text-primary underline"
href={`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/openapi`}
target="_blank"
>
here
</a>
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" teamId={team.id} />
<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} teamId={team.id}>
<Button variant="destructive">Delete</Button>
</DeleteTokenDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,206 +0,0 @@
'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,
FormDescription,
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 { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export type WebhookPageOptions = {
params: {
id: string;
};
};
export default function WebhookPage({ params }: WebhookPageOptions) {
const { toast } = useToast();
const router = useRouter();
const team = useCurrentTeam();
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
{
id: params.id,
teamId: team.id,
},
{ enabled: !!params.id && !!team.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: params.id,
teamId: team.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 max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
The URL for Documenso to send webhook events to.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>Enabled</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>Triggers</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
The events that will trigger a webhook to be sent to your URL.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update webhook
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@@ -1,106 +0,0 @@
'use client';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
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';
import { LocaleDate } from '~/components/formatter/locale-date';
import { useCurrentTeam } from '~/providers/team';
export default function WebhookPage() {
const team = useCurrentTeam();
const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({
teamId: team.id,
});
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={cn(
'border-border rounded-lg border p-4',
!webhook.enabled && 'bg-muted/40',
)}
>
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="truncate font-mono text-xs">{webhook.id}</div>
<div className="mt-1.5 flex items-center gap-2">
<h5
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
title={webhook.webhookUrl}
>
{webhook.webhookUrl}
</h5>
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
{webhook.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</p>
<p className="text-muted-foreground mt-2 text-xs">
Created on{' '}
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
</p>
</div>
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
<Button asChild variant="outline">
<Link href={`/t/${team.url}/settings/webhooks/${webhook.id}`}>Edit</Link>
</Button>
<DeleteWebhookDialog webhook={webhook}>
<Button variant="destructive">Delete</Button>
</DeleteWebhookDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,22 +0,0 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { TemplatePageViewProps } from '~/app/(dashboard)/templates/[id]/template-page-view';
import { TemplatePageView } from '~/app/(dashboard)/templates/[id]/template-page-view';
type TeamTemplatePageProps = {
params: TemplatePageViewProps['params'] & {
teamUrl: string;
};
};
export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <TemplatePageView params={params} team={team} />;
}

View File

@@ -1,26 +0,0 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { TemplatesPageViewProps } from '~/app/(dashboard)/templates/templates-page-view';
import { TemplatesPageView } from '~/app/(dashboard)/templates/templates-page-view';
type TeamTemplatesPageProps = {
searchParams?: TemplatesPageViewProps['searchParams'];
params: {
teamUrl: string;
};
};
export default async function TeamTemplatesPage({
searchParams = {},
params,
}: TeamTemplatesPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <TemplatesPageView searchParams={searchParams} team={team} />;
}

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
import { Mails } from 'lucide-react';
import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
export default function UnverifiedAccount() {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
<p className="text-muted-foreground mt-4">
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</p>
<p className="text-muted-foreground mt-4">
If you don't find the confirmation link in your inbox, you can request a new one below.
</p>
<SendConfirmationEmailForm />
</div>
</div>
);
}

View File

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

View File

@@ -2,11 +2,8 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
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 { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { TrpcProvider } from '@documenso/trpc/react';
@@ -22,35 +19,32 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export function generateMetadata() {
return {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
export const metadata = {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: ['/opengraph-image.jpg'],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: ['/opengraph-image.jpg'],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
}
type: 'website',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags();
@@ -68,7 +62,6 @@ 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="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<PublicEnvScript />
</head>
<Suspense>

View File

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

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