Compare commits
3 Commits
webhooks_p
...
feat/docum
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9eeaf1db8 | ||
|
|
82b87739d0 | ||
|
|
071475769c |
@@ -1,16 +1,4 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
|
||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
|
||||||
|
|
||||||
echo "Copying pdf.js"
|
|
||||||
npm run copy:pdfjs --workspace apps/**
|
|
||||||
|
|
||||||
echo "Copying .well-known/ contents"
|
|
||||||
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
|
||||||
|
|
||||||
git add "$MONOREPO_ROOT/apps/web/public/"
|
|
||||||
git add "$MONOREPO_ROOT/apps/marketing/public/"
|
|
||||||
|
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
|
|||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
date: 2024-01-25
|
date: 2024-01-25
|
||||||
tags:
|
Tags:
|
||||||
- Vision
|
- Vision
|
||||||
- Mission
|
- Mission
|
||||||
- Open Source
|
- Open Source
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
|
|||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
date: 2024-01-10
|
date: 2024-01-10
|
||||||
tags:
|
Tags:
|
||||||
- GitHub
|
- GitHub
|
||||||
- Backlog
|
- Backlog
|
||||||
- Roadmap
|
- Roadmap
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
|
|||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
date: 2024-02-06
|
date: 2024-02-06
|
||||||
tags:
|
Tags:
|
||||||
- Founders
|
- Founders
|
||||||
- Mission
|
- Mission
|
||||||
- Open Source
|
- Open Source
|
||||||
|
|||||||
@@ -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
|
|
||||||
56591
apps/marketing/public/pdf.worker.min.js
vendored
56591
apps/marketing/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -5,13 +5,14 @@ import { allDocuments } from 'contentlayer/generated';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const generateStaticParams = () =>
|
||||||
|
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
||||||
const document = allDocuments.find((doc) => doc._raw.flattenedPath === params.content);
|
const document = allDocuments.find((post) => post._raw.flattenedPath === params.content);
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return { title: 'Not Found' };
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { title: document.title };
|
return { title: document.title };
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ import { ChevronLeft } from 'lucide-react';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const generateStaticParams = () =>
|
||||||
|
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||||
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
||||||
|
|
||||||
if (!blogPost) {
|
if (!blogPost) {
|
||||||
return {
|
notFound();
|
||||||
title: 'Not Found',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { allBlogPosts } from 'contentlayer/generated';
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Blog',
|
title: 'Blog',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const blogPosts = allBlogPosts.sort((a, b) => {
|
const blogPosts = allBlogPosts.sort((a, b) => {
|
||||||
const dateA = new Date(a.date);
|
const dateA = new Date(a.date);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { redis } from '@documenso/lib/server-only/redis';
|
import { redis } from '@documenso/lib/server-only/redis';
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
@@ -13,8 +12,6 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
|
|
||||||
import { PasswordReveal } from '~/components/(marketing)/password-reveal';
|
import { PasswordReveal } from '~/components/(marketing)/password-reveal';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const fontCaveat = Caveat({
|
const fontCaveat = Caveat({
|
||||||
weight: ['500'],
|
weight: ['500'],
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
@@ -178,7 +175,11 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
This is a temporary password. Please change it as soon as possible.
|
This is a temporary password. Please change it as soon as possible.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link 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">
|
<Button size="lg" className="text-base">
|
||||||
Let's get started!
|
Let's get started!
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
|
|||||||
@@ -147,12 +147,7 @@ export default async function OpenPage() {
|
|||||||
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
|
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
|
||||||
All our metrics, finances, and learnings are public. We believe in transparency and want
|
All our metrics, finances, and learnings are public. We believe in transparency and want
|
||||||
to share our journey with you. You can read more about why here:{' '}
|
to share our journey with you. You can read more about why here:{' '}
|
||||||
<a
|
<a className="font-bold" href="https://documenso.com/blog/pre-seed" target="_blank">
|
||||||
className="font-bold"
|
|
||||||
href="https://documenso.com/blog/pre-seed"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Announcing Open Metrics
|
Announcing Open Metrics
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export const metadata: Metadata = {
|
|||||||
title: 'Pricing',
|
title: 'Pricing',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
export type PricingPageProps = {
|
export type PricingPageProps = {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
planId?: string;
|
planId?: string;
|
||||||
@@ -55,7 +53,7 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
<div className="mt-4 flex justify-center">
|
<div className="mt-4 flex justify-center">
|
||||||
<Button variant="outline" size="lg" className="rounded-full hover:cursor-pointer" asChild>
|
<Button variant="outline" size="lg" className="rounded-full hover:cursor-pointer" asChild>
|
||||||
<Link href="https://github.com/documenso/documenso" target="_blank" rel="noreferrer">
|
<Link href="https://github.com/documenso/documenso" target="_blank">
|
||||||
Get Started
|
Get Started
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -168,7 +166,6 @@ export default function PricingPage() {
|
|||||||
<Link
|
<Link
|
||||||
className="text-documenso-700 font-bold"
|
className="text-documenso-700 font-bold"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
|
||||||
href="mailto:support@documenso.com"
|
href="mailto:support@documenso.com"
|
||||||
>
|
>
|
||||||
support@documenso.com
|
support@documenso.com
|
||||||
@@ -178,7 +175,6 @@ export default function PricingPage() {
|
|||||||
className="text-documenso-700 font-bold"
|
className="text-documenso-700 font-bold"
|
||||||
href="https://documen.so/discord"
|
href="https://documen.so/discord"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
|
||||||
>
|
>
|
||||||
in our Discord-Support-Channel
|
in our Discord-Support-Channel
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
@@ -191,7 +190,7 @@ export const SinglePlayerClient = () => {
|
|||||||
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
|
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
|
||||||
Create a{' '}
|
Create a{' '}
|
||||||
<Link
|
<Link
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
|
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="hover:text-foreground/80 font-semibold transition-colors"
|
className="hover:text-foreground/80 font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
// !: This entire file is a hack to get around failed prerendering of
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
// !: the Single Player Mode page. This regression was introduced during
|
// !: the Single Player Mode page. This regression was introduced during
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Suspense } from 'react';
|
|||||||
import { Caveat, Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -18,35 +17,32 @@ import './globals.css';
|
|||||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||||
|
|
||||||
export function generateMetadata() {
|
export const metadata = {
|
||||||
return {
|
title: {
|
||||||
title: {
|
template: '%s - Documenso',
|
||||||
template: '%s - Documenso',
|
default: '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:
|
description:
|
||||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||||
keywords:
|
type: 'website',
|
||||||
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
|
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
|
||||||
authors: { name: 'Documenso, Inc.' },
|
},
|
||||||
robots: 'index, follow',
|
twitter: {
|
||||||
metadataBase: new URL(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3000'),
|
site: '@documenso',
|
||||||
openGraph: {
|
card: 'summary_large_image',
|
||||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
|
||||||
description:
|
description:
|
||||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||||
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.',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const flags = await getAllAnonymousFlags();
|
const flags = await getAllAnonymousFlags();
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Link from 'next/link';
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@@ -83,7 +82,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button className="rounded-full text-base" asChild>
|
<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
|
Signup Now
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -114,13 +117,13 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button className="mt-6 rounded-full text-base" asChild>
|
<Button className="mt-6 rounded-full text-base" asChild>
|
||||||
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}>Signup Now</Link>
|
<Link href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}>Signup Now</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<div className="mt-8 flex w-full flex-col divide-y">
|
||||||
<p className="text-foreground py-4 font-medium">
|
<p className="text-foreground py-4 font-medium">
|
||||||
{' '}
|
{' '}
|
||||||
<a href="https://documenso.com/blog/early-adopters" target="_blank" rel="noreferrer">
|
<a href="https://documenso.com/blog/early-adopters" target="_blank">
|
||||||
The Early Adopter Deal:
|
The Early Adopter Deal:
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -130,11 +133,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
<p className="text-foreground py-4">
|
<p className="text-foreground py-4">
|
||||||
<strong>
|
<strong>
|
||||||
{' '}
|
{' '}
|
||||||
<a
|
<a href="https://documenso.com/blog/early-adopters" target="_blank">
|
||||||
href="https://documenso.com/blog/early-adopters"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Includes all upcoming features
|
Includes all upcoming features
|
||||||
</a>
|
</a>
|
||||||
</strong>
|
</strong>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import type { Signature } from '@documenso/prisma/client';
|
import type { Signature } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
@@ -86,7 +85,7 @@ export const SinglePlayerModeSuccess = ({
|
|||||||
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
|
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
|
||||||
Create a{' '}
|
Create a{' '}
|
||||||
<Link
|
<Link
|
||||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
|
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
|
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { usePlausible } from 'next-plausible';
|
import { usePlausible } from 'next-plausible';
|
||||||
import { env } from 'next-runtime-env';
|
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -145,11 +144,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
setTimeout(resolve, 1000);
|
setTimeout(resolve, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
const planId = env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID');
|
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
||||||
|
|
||||||
if (!planId) {
|
|
||||||
throw new Error('No plan ID found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const claimPlanInput = signatureDataUrl
|
const claimPlanInput = signatureDataUrl
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
import type { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
|
import { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
|
||||||
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { redis } from '@documenso/lib/server-only/redis';
|
import { redis } from '@documenso/lib/server-only/redis';
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import type { TClaimPlanResponseSchema } from '~/api/claim-plan/types';
|
import { TClaimPlanResponseSchema, ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
|
||||||
import { ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
|
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
@@ -42,7 +40,7 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return res.status(200).json({
|
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',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
success_url: `${NEXT_PUBLIC_MARKETING_URL()}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${NEXT_PUBLIC_MARKETING_URL()}`,
|
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!checkout.url) {
|
if (!checkout.url) {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/api": "*",
|
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
"@documenso/ee": "*",
|
"@documenso/ee": "*",
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
'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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
'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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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="w-px bg-gray-200" />
|
||||||
|
</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.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
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 { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import type { Team } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
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 { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
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';
|
||||||
|
|
||||||
export type DocumentPageViewProps = {
|
export type DocumentPageViewProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -67,65 +78,120 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
const recipients = await getRecipientsForDocument({
|
||||||
getRecipientsForDocument({
|
documentId,
|
||||||
documentId,
|
userId: user.id,
|
||||||
userId: user.id,
|
});
|
||||||
}),
|
|
||||||
getFieldsForDocument({
|
const documentWithRecipients = {
|
||||||
documentId,
|
...document,
|
||||||
userId: user.id,
|
Recipient: recipients,
|
||||||
}),
|
};
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<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">
|
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<div className="flex flex-row justify-between">
|
||||||
{document.title}
|
<div>
|
||||||
</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">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
<DocumentStatusComponent
|
||||||
|
inheritColor
|
||||||
|
status={document.status}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
{recipients.length > 0 && (
|
||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
<EditDocumentForm
|
<Card
|
||||||
className="mt-8"
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
document={document}
|
gradient
|
||||||
user={user}
|
>
|
||||||
documentMeta={documentMeta}
|
<CardContent className="p-2">
|
||||||
recipients={recipients}
|
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||||
fields={fields}
|
</CardContent>
|
||||||
documentData={documentData}
|
</Card>
|
||||||
documentRootPath={documentRootPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
<div className="space-y-6">
|
||||||
<LazyPDFViewer
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
document={document}
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
key={documentData.id}
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
documentMeta={documentMeta}
|
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
|
||||||
documentData={documentData}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
|
import {
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
type DocumentData,
|
||||||
|
type DocumentMeta,
|
||||||
|
DocumentStatus,
|
||||||
|
type Field,
|
||||||
|
type Recipient,
|
||||||
|
type User,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -49,12 +55,9 @@ export const EditDocumentForm = ({
|
|||||||
documentRootPath,
|
documentRootPath,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// controlled stepper state
|
const router = useRouter();
|
||||||
const [step, setStep] = useState<EditDocumentStep>(
|
const searchParams = useSearchParams();
|
||||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||||
@@ -86,6 +89,24 @@ 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) => {
|
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// Custom invocation server action
|
// Custom invocation server action
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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,
|
||||||
|
}),
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
11
apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx
Normal file
11
apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { DocumentEditPageView } from './document-edit-page-view';
|
||||||
|
|
||||||
|
export type DocumentPageProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DocumentEditPage({ params }: DocumentPageProps) {
|
||||||
|
return <DocumentEditPageView params={params} />;
|
||||||
|
}
|
||||||
@@ -108,86 +108,88 @@ export const ResendDocumentActionItem = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<>
|
||||||
<DialogTrigger asChild>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
<DialogTrigger asChild>
|
||||||
<History className="mr-2 h-4 w-4" />
|
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||||
Resend
|
<History className="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
Resend
|
||||||
</DialogTrigger>
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-sm" hideClose>
|
<DialogContent className="sm:max-w-sm" hideClose>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle asChild>
|
||||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="recipients"
|
name="recipients"
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<>
|
<>
|
||||||
{recipients.map((recipient) => (
|
{recipients.map((recipient) => (
|
||||||
<FormItem
|
<FormItem
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
className="flex flex-row items-center justify-between gap-x-3"
|
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),
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<StackAvatar
|
<FormLabel
|
||||||
key={recipient.id}
|
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||||
type={getRecipientType(recipient)}
|
'opacity-50': !value.includes(recipient.id),
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
})}
|
||||||
/>
|
>
|
||||||
{recipient.email}
|
<StackAvatar
|
||||||
</FormLabel>
|
key={recipient.id}
|
||||||
|
type={getRecipientType(recipient)}
|
||||||
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
|
/>
|
||||||
|
{recipient.email}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
|
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
|
||||||
checkClassName="text-white"
|
checkClassName="text-white"
|
||||||
value={recipient.id}
|
value={recipient.id}
|
||||||
checked={value.includes(recipient.id)}
|
checked={value.includes(recipient.id)}
|
||||||
onCheckedChange={(checked: boolean) =>
|
onCheckedChange={(checked: boolean) =>
|
||||||
checked
|
checked
|
||||||
? onChange([...value, recipient.id])
|
? onChange([...value, recipient.id])
|
||||||
: onChange(value.filter((v) => v !== recipient.id))
|
: onChange(value.filter((v) => v !== recipient.id))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||||
|
Send reminder
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
</DialogContent>
|
||||||
Send reminder
|
</Dialog>
|
||||||
</Button>
|
</>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||||
() => (
|
() => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}`}>
|
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}`}>
|
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import Link from 'next/link';
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { Document, Recipient, User } from '@documenso/prisma/client';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type DataTableTitleProps = {
|
export type DataTableTitleProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
team: Pick<Team, 'url'> | null;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
};
|
};
|
||||||
|
teamUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -25,14 +28,18 @@ export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
|||||||
|
|
||||||
const isOwner = row.User.id === session.user.id;
|
const isOwner = row.User.id === session.user.id;
|
||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
|
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
isRecipient,
|
isRecipient,
|
||||||
|
isCurrentTeamDocument,
|
||||||
})
|
})
|
||||||
.with({ isOwner: true }, () => (
|
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
||||||
<Link
|
<Link
|
||||||
href={`/documents/${row.id}`}
|
href={`${documentsPath}/${row.id}`}
|
||||||
title={row.title}
|
title={row.title}
|
||||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const DocumentsDataTable = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sender',
|
id: 'sender',
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: (newId) => {
|
onSuccess: (newId) => {
|
||||||
router.push(`${documentsPath}/${newId}`);
|
router.push(`${documentsPath}/${newId}/edit`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: 'Document Duplicated',
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
|
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone
|
<DocumentDropzone
|
||||||
className="h-[min(400px,50vh)]"
|
className="min-h-[40vh]"
|
||||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
export const createBillingPortal = async () => {
|
export const createBillingPortal = async () => {
|
||||||
@@ -12,6 +11,6 @@ export const createBillingPortal = async () => {
|
|||||||
|
|
||||||
return getPortalSession({
|
return getPortalSession({
|
||||||
customerId: stripeCustomer.id,
|
customerId: stripeCustomer.id,
|
||||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||||
|
|
||||||
@@ -28,13 +27,13 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
|||||||
if (foundSubscription) {
|
if (foundSubscription) {
|
||||||
return getPortalSession({
|
return getPortalSession({
|
||||||
customerId: stripeCustomer.id,
|
customerId: stripeCustomer.id,
|
||||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return getCheckoutSession({
|
return getCheckoutSession({
|
||||||
customerId: stripeCustomer.id,
|
customerId: stripeCustomer.id,
|
||||||
priceId,
|
priceId,
|
||||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
import { 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 { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
@@ -37,23 +37,23 @@ export default async function BillingSettingsPage() {
|
|||||||
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
const [subscriptions, prices, communityPlanPrices] = await Promise.all([
|
||||||
getSubscriptionsByUserId({ userId: user.id }),
|
getSubscriptionsByUserId({ userId: user.id }),
|
||||||
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
|
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;
|
let subscriptionProduct: Stripe.Product | null = null;
|
||||||
|
|
||||||
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
|
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||||
primaryAccountPlanPriceIds.includes(priceId),
|
communityPlanPriceIds.includes(priceId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const subscription =
|
const subscription =
|
||||||
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||||
primaryAccountPlanSubscriptions[0];
|
communityPlanUserSubscriptions[0];
|
||||||
|
|
||||||
if (subscription?.priceId) {
|
if (subscription?.priceId) {
|
||||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
import { ApiTokenForm } from '~/components/forms/token';
|
|
||||||
|
|
||||||
export default async function ApiTokensPage() {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const tokens = await getUserTokens({ userId: user.id });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
On this page, you can create new API tokens and manage the existing ones.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<ApiTokenForm className="max-w-xl" />
|
|
||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
|
||||||
|
|
||||||
<h4 className="text-xl font-medium">Your existing tokens</h4>
|
|
||||||
|
|
||||||
{tokens.length === 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
|
||||||
Your tokens will be shown here once you create them.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tokens.length > 0 && (
|
|
||||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
|
||||||
{tokens.map((token) => (
|
|
||||||
<div key={token.id} className="border-border rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between gap-x-4">
|
|
||||||
<div>
|
|
||||||
<h5 className="text-base">{token.name}</h5>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
|
||||||
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
|
|
||||||
</p>
|
|
||||||
{token.expires ? (
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
|
||||||
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
|
||||||
Token doesn't have an expiration date
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<DeleteTokenDialog token={token}>
|
|
||||||
<Button variant="destructive">Delete</Button>
|
|
||||||
</DeleteTokenDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,169 +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,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
import { MultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/multiselect-combobox';
|
|
||||||
|
|
||||||
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
|
||||||
|
|
||||||
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
|
||||||
|
|
||||||
export type WebhookPageOptions = {
|
|
||||||
params: {
|
|
||||||
id: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WebhookPage({ params }: WebhookPageOptions) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
|
||||||
{
|
|
||||||
id: Number(params.id),
|
|
||||||
},
|
|
||||||
{ enabled: !!params.id },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TEditWebhookFormSchema>({
|
|
||||||
resolver: zodResolver(ZEditWebhookFormSchema),
|
|
||||||
values: {
|
|
||||||
webhookUrl: webhook?.webhookUrl ?? '',
|
|
||||||
eventTriggers: webhook?.eventTriggers ?? [],
|
|
||||||
secret: webhook?.secret ?? '',
|
|
||||||
enabled: webhook?.enabled ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: TEditWebhookFormSchema) => {
|
|
||||||
try {
|
|
||||||
await updateWebhook({
|
|
||||||
id: Number(params.id),
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Webhook updated',
|
|
||||||
description: 'The webhook has been updated successfully.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to update webhook',
|
|
||||||
description: 'We encountered an error while updating the webhook. Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader
|
|
||||||
title="Edit webhook"
|
|
||||||
subtitle="On this page, you can edit the webhook and its settings."
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="webhookUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel htmlFor="webhookUrl">Webhook URL</FormLabel>
|
|
||||||
<Input {...field} id="webhookUrl" type="text" />
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="eventTriggers"
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<FormItem className="flex flex-col">
|
|
||||||
<FormLabel required>Event triggers</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
listValues={value}
|
|
||||||
onChange={(values: string[]) => {
|
|
||||||
onChange(values);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="secret"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Secret</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center gap-x-2">
|
|
||||||
<FormLabel className="mt-2">Active</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
className="bg-background"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
Update webhook
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Zap } from 'lucide-react';
|
|
||||||
import { ToggleLeft, ToggleRight } from 'lucide-react';
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
|
|
||||||
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
|
|
||||||
|
|
||||||
export default function WebhookPage() {
|
|
||||||
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader
|
|
||||||
title="Webhooks"
|
|
||||||
subtitle="On this page, you can create new Webhooks and manage the existing ones."
|
|
||||||
>
|
|
||||||
<CreateWebhookDialog />
|
|
||||||
</SettingsHeader>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
|
||||||
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{webhooks && webhooks.length === 0 && (
|
|
||||||
// TODO: Perhaps add some illustrations here to make the page more engaging
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
|
||||||
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{webhooks && webhooks.length > 0 && (
|
|
||||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
|
||||||
{webhooks?.map((webhook) => (
|
|
||||||
<div key={webhook.id} className="border-border rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between gap-x-4">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-semibold">Webhook URL</h4>
|
|
||||||
<p className="text-muted-foreground">{webhook.webhookUrl}</p>
|
|
||||||
<h4 className="mt-4 text-lg font-semibold">Event triggers</h4>
|
|
||||||
{webhook.eventTriggers.map((trigger, index) => (
|
|
||||||
<span key={index} className="text-muted-foreground flex flex-row items-center">
|
|
||||||
<Zap className="mr-1 h-4 w-4" /> {trigger}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{webhook.enabled ? (
|
|
||||||
<h4 className="mt-4 flex items-center gap-2 text-lg">
|
|
||||||
Active <ToggleRight className="h-6 w-6 fill-green-200 stroke-green-400" />
|
|
||||||
</h4>
|
|
||||||
) : (
|
|
||||||
<h4 className="mt-4 flex items-center gap-2 text-lg">
|
|
||||||
Inactive <ToggleLeft className="h-6 w-6 fill-slate-200 stroke-slate-400" />
|
|
||||||
</h4>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex flex-col-reverse space-y-2 space-y-reverse sm:mt-0 sm:flex-row sm:justify-end sm:space-x-2 sm:space-y-0">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
|
|
||||||
</Button>
|
|
||||||
<DeleteWebhookDialog webhook={webhook}>
|
|
||||||
<Button variant="destructive">Delete</Button>
|
|
||||||
</DeleteWebhookDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -76,7 +76,7 @@ export const TemplatesDataTable = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`${documentRootPath}/${id}`);
|
router.push(`${documentRootPath}/${id}/edit`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
|
|
||||||
import type { ShareHandlerAPIResponse } from '~/pages/api/share';
|
import type { ShareHandlerAPIResponse } from '~/pages/api/share';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
@@ -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(
|
const recipientOrSender: ShareHandlerAPIResponse = await fetch(
|
||||||
new URL(`/api/share?slug=${slug}`, baseUrl),
|
new URL(`/api/share?slug=${slug}`, baseUrl),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { redirect } from 'next/navigation';
|
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 = {
|
type SharePageProps = {
|
||||||
params: { slug: string };
|
params: { slug: string };
|
||||||
@@ -16,12 +16,12 @@ export function generateMetadata({ params: { slug } }: SharePageProps) {
|
|||||||
title: 'Documenso - Join the open source signing revolution',
|
title: 'Documenso - Join the open source signing revolution',
|
||||||
description: 'I just signed with Documenso!',
|
description: 'I just signed with Documenso!',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
images: [`/share/${slug}/opengraph`],
|
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
site: '@documenso',
|
site: '@documenso',
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
images: [`/share/${slug}/opengraph`],
|
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||||
description: 'I just signed with Documenso!',
|
description: 'I just signed with Documenso!',
|
||||||
},
|
},
|
||||||
} satisfies Metadata;
|
} satisfies Metadata;
|
||||||
@@ -35,5 +35,5 @@ export default function SharePage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001');
|
redirect(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export type TeamTransferStatusProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
currentUserTeamRole: TeamMemberRole;
|
currentUserTeamRole: TeamMemberRole;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
|
transferVerification: TeamTransferVerification | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TeamTransferStatus = ({
|
export const TeamTransferStatus = ({
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import type { Metadata } from 'next';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { env } from 'next-runtime-env';
|
|
||||||
|
|
||||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
|
||||||
@@ -20,8 +18,6 @@ type SignInPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SignInPage({ searchParams }: SignInPageProps) {
|
export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
|
||||||
|
|
||||||
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||||
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||||
|
|
||||||
@@ -43,7 +39,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
|
|||||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
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">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import type { Metadata } from 'next';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { env } from 'next-runtime-env';
|
|
||||||
|
|
||||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
|
||||||
@@ -20,9 +18,7 @@ type SignUpPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||||
|
|
||||||
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
|
||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
export { OpenApiDocsPage as default } from '@documenso/api/v1/api-documentation';
|
|
||||||
@@ -2,11 +2,8 @@ import { Suspense } from 'react';
|
|||||||
|
|
||||||
import { Caveat, Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
import { PublicEnvScript } from 'next-runtime-env';
|
|
||||||
|
|
||||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
import { TrpcProvider } from '@documenso/trpc/react';
|
||||||
@@ -22,35 +19,32 @@ import './globals.css';
|
|||||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||||
|
|
||||||
export function generateMetadata() {
|
export const metadata = {
|
||||||
return {
|
title: {
|
||||||
title: {
|
template: '%s - Documenso',
|
||||||
template: '%s - Documenso',
|
default: '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:
|
description:
|
||||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||||
keywords:
|
type: 'website',
|
||||||
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
|
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
|
||||||
authors: { name: 'Documenso, Inc.' },
|
},
|
||||||
robots: 'index, follow',
|
twitter: {
|
||||||
metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
|
site: '@documenso',
|
||||||
openGraph: {
|
card: 'summary_large_image',
|
||||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
|
||||||
description:
|
description:
|
||||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||||
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.',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const flags = await getServerComponentAllFlags();
|
const flags = await getServerComponentAllFlags();
|
||||||
@@ -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="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<PublicEnvScript />
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import React from 'react';
|
|||||||
|
|
||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
@@ -26,7 +25,7 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
|
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Copied to clipboard',
|
title: 'Copied to clipboard',
|
||||||
description: 'The signing link has been copied to your clipboard.',
|
description: 'The signing link has been copied to your clipboard.',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Braces,
|
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Lock,
|
Lock,
|
||||||
@@ -99,13 +98,6 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/settings/tokens" className="cursor-pointer">
|
|
||||||
<Braces className="mr-2 h-4 w-4" />
|
|
||||||
API Tokens
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/billing" className="cursor-pointer">
|
<Link href="/settings/billing" className="cursor-pointer">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -51,19 +51,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link href="/settings/webhooks">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Webhook className="mr-2 h-5 w-5" />
|
|
||||||
Webhooks
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -77,19 +64,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/tokens">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Braces className="mr-2 h-5 w-5" />
|
|
||||||
API Tokens
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -54,19 +54,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link href="/settings/webhooks">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Webhook className="mr-2 h-5 w-5" />
|
|
||||||
Webhooks
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -80,19 +67,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/tokens">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Braces className="mr-2 h-5 w-5" />
|
|
||||||
API Tokens
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
export const EXPIRATION_DATES = {
|
|
||||||
ONE_WEEK: '7 days',
|
|
||||||
ONE_MONTH: '1 month',
|
|
||||||
THREE_MONTHS: '3 months',
|
|
||||||
SIX_MONTHS: '6 months',
|
|
||||||
ONE_YEAR: '12 months',
|
|
||||||
} as const;
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import type { ApiToken } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type DeleteTokenDialogProps = {
|
|
||||||
token: Pick<ApiToken, 'id' | 'name'>;
|
|
||||||
onDelete?: () => void;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DeleteTokenDialog({ token, onDelete, children }: DeleteTokenDialogProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const deleteMessage = `delete ${token.name}`;
|
|
||||||
|
|
||||||
const ZDeleteTokenDialogSchema = z.object({
|
|
||||||
tokenName: z.literal(deleteMessage, {
|
|
||||||
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenDialogSchema>;
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
onDelete?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<TDeleteTokenByIdMutationSchema>({
|
|
||||||
resolver: zodResolver(ZDeleteTokenDialogSchema),
|
|
||||||
values: {
|
|
||||||
tokenName: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await deleteTokenMutation({
|
|
||||||
id: token.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Token deleted',
|
|
||||||
description: 'The token was deleted successfully.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsOpen(false);
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 5000,
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to delete this token. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isOpen, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild={true}>
|
|
||||||
{children ?? (
|
|
||||||
<Button className="mr-4" variant="destructive">
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Are you sure you want to delete this token?</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
Please note that this action is irreversible. Once confirmed, your token will be
|
|
||||||
permanently deleted.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="tokenName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Confirm by typing:{' '}
|
|
||||||
<span className="font-sm text-destructive font-semibold">
|
|
||||||
{deleteMessage}
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<div className="flex w-full flex-nowrap gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!form.formState.isValid}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
I'm sure! Delete it
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateWebhookFormSchema } from '@documenso/trpc/server/webhook-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { MultiSelectCombobox } from './multiselect-combobox';
|
|
||||||
|
|
||||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
|
||||||
|
|
||||||
export type CreateWebhookDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<TCreateWebhookFormSchema>({
|
|
||||||
resolver: zodResolver(ZCreateWebhookFormSchema),
|
|
||||||
values: {
|
|
||||||
webhookUrl: '',
|
|
||||||
eventTriggers: [],
|
|
||||||
secret: '',
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
|
|
||||||
|
|
||||||
const onSubmit = async (values: TCreateWebhookFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createWebhook(values);
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Webhook created',
|
|
||||||
description: 'The webhook was successfully created.',
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'An error occurred while creating the webhook. Please try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
{trigger ?? <Button className="flex-shrink-0">Create Webhook</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create webhook</DialogTitle>
|
|
||||||
<DialogDescription>On this page, you can create a new webhook.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="webhookUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>Webhook URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="eventTriggers"
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<FormItem className="flex flex-col gap-2">
|
|
||||||
<FormLabel required>Event triggers</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
listValues={value}
|
|
||||||
onChange={(values: string[]) => {
|
|
||||||
onChange(values);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="secret"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Secret</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput
|
|
||||||
className="bg-background"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center gap-2">
|
|
||||||
<FormLabel className="mt-2">Active</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
className="bg-background"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<div className="flex w-full flex-nowrap gap-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
'use effect';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import type { Webhook } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type DeleteWebhookDialogProps = {
|
|
||||||
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
|
||||||
onDelete?: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const deleteMessage = `delete ${webhook.webhookUrl}`;
|
|
||||||
|
|
||||||
const ZDeleteWebhookFormSchema = z.object({
|
|
||||||
webhookUrl: z.literal(deleteMessage, {
|
|
||||||
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TDeleteWebhookFormSchema = z.infer<typeof ZDeleteWebhookFormSchema>;
|
|
||||||
|
|
||||||
const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TDeleteWebhookFormSchema>({
|
|
||||||
resolver: zodResolver(ZDeleteWebhookFormSchema),
|
|
||||||
values: {
|
|
||||||
webhookUrl: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await deleteWebhook({ id: webhook.id });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Webhook deleted',
|
|
||||||
duration: 5000,
|
|
||||||
description: 'The webhook has been successfully deleted.',
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 5000,
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to delete it. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? (
|
|
||||||
<Button className="mr-4" variant="destructive">
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Webhook</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
Please note that this action is irreversible. Once confirmed, your webhook will be
|
|
||||||
permanently deleted.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="webhookUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Confirm by typing:{' '}
|
|
||||||
<span className="font-sm text-destructive font-semibold">
|
|
||||||
{deleteMessage}
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<div className="flex w-full flex-nowrap gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!form.formState.isValid}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
I'm sure! Delete it
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
import { WebhookTriggerEvents } from '@prisma/client/';
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
} from '@documenso/ui/primitives/command';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
|
||||||
|
|
||||||
type ComboboxProps = {
|
|
||||||
listValues: string[];
|
|
||||||
onChange: (_values: string[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
|
||||||
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
|
||||||
|
|
||||||
const triggerEvents = Object.values(WebhookTriggerEvents);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setSelectedValues(listValues);
|
|
||||||
}, [listValues]);
|
|
||||||
|
|
||||||
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
|
|
||||||
|
|
||||||
const handleSelect = (currentValue: string) => {
|
|
||||||
let newSelectedValues;
|
|
||||||
if (selectedValues.includes(currentValue)) {
|
|
||||||
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
|
|
||||||
} else {
|
|
||||||
newSelectedValues = [...selectedValues, currentValue];
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedValues(newSelectedValues);
|
|
||||||
onChange(newSelectedValues);
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
className="w-[200px] justify-between"
|
|
||||||
>
|
|
||||||
{selectedValues.length > 0 ? selectedValues.length + ' selected...' : 'Select values...'}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="z-9999 w-[200px] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder={truncateTitle(selectedValues.join(', '), 15)} />
|
|
||||||
<CommandEmpty>No value found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{allEvents.map((value: string, i: number) => (
|
|
||||||
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'mr-2 h-4 w-4',
|
|
||||||
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{value}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { MultiSelectCombobox };
|
|
||||||
@@ -238,7 +238,7 @@ export const TransferTeamDialog = ({
|
|||||||
<Alert variant="neutral">
|
<Alert variant="neutral">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<ul className="list-outside list-disc space-y-2 pl-4">
|
<ul className="list-outside list-disc space-y-2 pl-4">
|
||||||
{IS_BILLING_ENABLED() && (
|
{IS_BILLING_ENABLED && (
|
||||||
// Temporary removed.
|
// Temporary removed.
|
||||||
// <li>
|
// <li>
|
||||||
// {form.getValues('clearPaymentMethods')
|
// {form.getValues('clearPaymentMethods')
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{IS_BILLING_ENABLED() && (
|
{IS_BILLING_ENABLED && (
|
||||||
<Link href={billingPath}>
|
<Link href={billingPath}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{IS_BILLING_ENABLED() && (
|
{IS_BILLING_ENABLED && (
|
||||||
<Link href={billingPath}>
|
<Link href={billingPath}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const TeamsMemberPageDataTable = ({
|
|||||||
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger className="min-w-[60px]" value="members" asChild>
|
<TabsTrigger className="min-w-[60px]" value="members" asChild>
|
||||||
<Link href={pathname ?? '/'}>Active</Link>
|
<Link href={pathname ?? '/'}>All</Link>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
||||||
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
|
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
|
||||||
|
export type DocumentHistorySheetChangesProps = {
|
||||||
|
values: {
|
||||||
|
key: string | React.ReactNode;
|
||||||
|
value: string | React.ReactNode;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="text-muted-foreground mt-3 block w-full space-y-0.5 text-xs"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
{values.map(({ key, value }, i) => (
|
||||||
|
<p key={typeof key === 'string' ? key : i}>
|
||||||
|
<span>{key}: </span>
|
||||||
|
<span className="font-normal">{value}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
316
apps/web/src/components/document/document-history-sheet.tsx
Normal file
316
apps/web/src/components/document/document-history-sheet.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ArrowRightIcon, Loader } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||||
|
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
import { DocumentHistorySheetChanges } from './document-history-sheet-changes';
|
||||||
|
|
||||||
|
export type DocumentHistorySheetProps = {
|
||||||
|
documentId: number;
|
||||||
|
userId: number;
|
||||||
|
isMenuOpen?: boolean;
|
||||||
|
onMenuOpenChange?: (_value: boolean) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentHistorySheet = ({
|
||||||
|
documentId,
|
||||||
|
userId,
|
||||||
|
isMenuOpen,
|
||||||
|
onMenuOpenChange,
|
||||||
|
children,
|
||||||
|
}: DocumentHistorySheetProps) => {
|
||||||
|
const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isLoadingError,
|
||||||
|
refetch,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
|
||||||
|
{
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
|
||||||
|
|
||||||
|
const extractBrowser = (userAgent?: string | null) => {
|
||||||
|
if (!userAgent) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new UAParser(userAgent);
|
||||||
|
|
||||||
|
parser.setUA(userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
return result.browser.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the following formatting for a given text:
|
||||||
|
* - Uppercase first lower, lowercase rest
|
||||||
|
* - Replace _ with spaces
|
||||||
|
*
|
||||||
|
* @param text The text to format
|
||||||
|
* @returns The formatted text
|
||||||
|
*/
|
||||||
|
const formatGenericText = (text: string) => {
|
||||||
|
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
|
{children && <SheetTrigger asChild>{children}</SheetTrigger>}
|
||||||
|
|
||||||
|
<SheetContent
|
||||||
|
sheetClass="backdrop-blur-none"
|
||||||
|
className="flex w-full max-w-[500px] flex-col overflow-y-auto p-0"
|
||||||
|
>
|
||||||
|
<div className="text-foreground px-6 pt-6">
|
||||||
|
<h1 className="text-lg font-medium">Document history</h1>
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground text-sm"
|
||||||
|
onClick={() => setIsUserDetailsVisible(!isUserDetailsVisible)}
|
||||||
|
>
|
||||||
|
{isUserDetailsVisible ? 'Hide' : 'Show'} additional information
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoadingError && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<ul
|
||||||
|
className={cn('divide-y border-t', {
|
||||||
|
'mb-4 border-b': !hasNextPage,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{documentAuditLogs.map((auditLog) => (
|
||||||
|
<li className="px-4 py-2.5" key={auditLog.id}>
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Avatar className="mr-2 h-9 w-9">
|
||||||
|
<AvatarFallback className="text-xs text-gray-400">
|
||||||
|
{(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground text-xs font-bold">
|
||||||
|
{formatDocumentAuditLogActionString(auditLog, userId)}
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground/50 text-xs">
|
||||||
|
<LocaleDate date={auditLog.createdAt} format="d MMM, yyyy HH:MM a" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{match(auditLog)
|
||||||
|
.with(
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
|
||||||
|
() => null,
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED },
|
||||||
|
({ data }) => {
|
||||||
|
const values = [
|
||||||
|
{
|
||||||
|
key: 'Email',
|
||||||
|
value: data.recipientEmail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Role',
|
||||||
|
value: formatGenericText(data.recipientRole),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (data.recipientName) {
|
||||||
|
values.unshift({
|
||||||
|
key: 'Name',
|
||||||
|
value: data.recipientName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DocumentHistorySheetChanges values={values} />;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => {
|
||||||
|
if (data.changes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={data.changes.map(({ type, from, to }) => ({
|
||||||
|
key: formatGenericText(type),
|
||||||
|
value: (
|
||||||
|
<span className="inline-flex flex-row items-center">
|
||||||
|
<span>{type === 'ROLE' ? formatGenericText(from) : from}</span>
|
||||||
|
<ArrowRightIcon className="h-4 w-4" />
|
||||||
|
<span>{type === 'ROLE' ? formatGenericText(to) : to}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with(
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED },
|
||||||
|
({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Field',
|
||||||
|
value: formatGenericText(data.fieldType),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Recipient',
|
||||||
|
value: formatGenericText(data.fieldRecipientEmail),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
||||||
|
if (data.changes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={data.changes.map((change) => ({
|
||||||
|
key: formatGenericText(change.type),
|
||||||
|
value: change.type === 'PASSWORD' ? '*********' : change.to,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Old',
|
||||||
|
value: data.from,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'New',
|
||||||
|
value: data.to,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Field inserted',
|
||||||
|
value: formatGenericText(data.field.type),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Field uninserted',
|
||||||
|
value: formatGenericText(data.field),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Type',
|
||||||
|
value: DOCUMENT_AUDIT_LOG_EMAIL_FORMAT[data.emailType].description,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Sent to',
|
||||||
|
value: data.recipientEmail,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
|
||||||
|
{isUserDetailsVisible && (
|
||||||
|
<>
|
||||||
|
<div className="mb-1 mt-2 flex flex-row space-x-2">
|
||||||
|
<Badge variant="neutral" className="text-muted-foreground">
|
||||||
|
IP: {auditLog.ipAddress ?? 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Badge variant="neutral" className="text-muted-foreground">
|
||||||
|
Browser: {extractBrowser(auditLog.userAgent)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
loading={isFetchingNextPage}
|
||||||
|
onClick={async () => fetchNextPage()}
|
||||||
|
>
|
||||||
|
Show more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ type FriendlyStatus = {
|
|||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||||
PENDING: {
|
PENDING: {
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@@ -10,7 +10,7 @@ import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
|||||||
|
|
||||||
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
date: string | number | Date;
|
date: string | number | Date;
|
||||||
format?: DateTimeFormatOptions;
|
format?: DateTimeFormatOptions | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,13 +22,24 @@ export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
|||||||
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
|
|
||||||
|
const formatDateTime = useCallback(
|
||||||
|
(date: DateTime) => {
|
||||||
|
if (typeof format === 'string') {
|
||||||
|
return date.toFormat(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString(format);
|
||||||
|
},
|
||||||
|
[format],
|
||||||
|
);
|
||||||
|
|
||||||
const [localeDate, setLocaleDate] = useState(() =>
|
const [localeDate, setLocaleDate] = useState(() =>
|
||||||
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
|
formatDateTime(DateTime.fromJSDate(new Date(date)).setLocale(locale)),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
|
setLocaleDate(formatDateTime(DateTime.fromJSDate(new Date(date))));
|
||||||
}, [date, format]);
|
}, [date, format, formatDateTime]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={className} {...props}>
|
<span className={className} {...props}>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { renderSVG } from 'uqr';
|
import { renderSVG } from 'uqr';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -52,16 +54,14 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: EnableAuthenticatorAppDialogProps) => {
|
}: EnableAuthenticatorAppDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||||
trpc.twoFactorAuthentication.setup.useMutation();
|
trpc.twoFactorAuthentication.setup.useMutation();
|
||||||
|
|
||||||
const {
|
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
|
||||||
mutateAsync: enableTwoFactorAuthentication,
|
trpc.twoFactorAuthentication.enable.useMutation();
|
||||||
data: enableTwoFactorAuthenticationData,
|
|
||||||
isLoading: isEnableTwoFactorAuthenticationDataLoading,
|
|
||||||
} = trpc.twoFactorAuthentication.enable.useMutation();
|
|
||||||
|
|
||||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -115,19 +115,6 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadRecoveryCodes = () => {
|
|
||||||
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
|
|
||||||
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
|
|
||||||
type: 'text/plain',
|
|
||||||
});
|
|
||||||
|
|
||||||
downloadFile({
|
|
||||||
filename: 'documenso-2FA-recovery-codes.txt',
|
|
||||||
data: blob,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
||||||
token,
|
token,
|
||||||
}: TEnableTwoFactorAuthenticationForm) => {
|
}: TEnableTwoFactorAuthenticationForm) => {
|
||||||
@@ -149,6 +136,14 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onCompleteClick = () => {
|
||||||
|
flushSync(() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
@@ -275,16 +270,9 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
||||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
<Button type="button" onClick={() => onCompleteClick()}>
|
||||||
|
Complete
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={downloadRecoveryCodes}
|
|
||||||
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
|
|
||||||
loading={isEnableTwoFactorAuthenticationDataLoading}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -43,11 +42,8 @@ export type ViewRecoveryCodesDialogProps = {
|
|||||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
|
||||||
mutateAsync: viewRecoveryCodes,
|
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||||
data: viewRecoveryCodesData,
|
|
||||||
isLoading: isViewRecoveryCodesDataLoading,
|
|
||||||
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
|
||||||
|
|
||||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -66,19 +62,6 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
|||||||
return 'view';
|
return 'view';
|
||||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
||||||
|
|
||||||
const downloadRecoveryCodes = () => {
|
|
||||||
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
|
|
||||||
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
|
|
||||||
type: 'text/plain',
|
|
||||||
});
|
|
||||||
|
|
||||||
downloadFile({
|
|
||||||
filename: 'documenso-2FA-recovery-codes.txt',
|
|
||||||
data: blob,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
||||||
try {
|
try {
|
||||||
await viewRecoveryCodes({ password });
|
await viewRecoveryCodes({ password });
|
||||||
@@ -156,17 +139,8 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
|||||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
<div className="mt-4 flex flex-row-reverse items-center justify-between">
|
||||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!viewRecoveryCodesData?.recoveryCodes}
|
|
||||||
loading={isViewRecoveryCodesDataLoading}
|
|
||||||
onClick={downloadRecoveryCodes}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export const ZSendConfirmationEmailFormSchema = z.object({
|
|
||||||
email: z.string().email().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
|
|
||||||
|
|
||||||
export type SendConfirmationEmailFormProps = {
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFormProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<TSendConfirmationEmailFormSchema>({
|
|
||||||
values: {
|
|
||||||
email: '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZSendConfirmationEmailFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
|
||||||
|
|
||||||
const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => {
|
|
||||||
try {
|
|
||||||
await sendConfirmationEmail({ email });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Confirmation email sent',
|
|
||||||
description:
|
|
||||||
'A confirmation email has been sent, and it should arrive in your inbox shortly.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'An error occurred while sending your confirmation email',
|
|
||||||
description: 'Please try again and make sure you enter the correct email address.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
className={cn('mt-6 flex w-full flex-col gap-y-4', className)}
|
|
||||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email address</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="email" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
|
|
||||||
<Button size="lg" type="submit" disabled={isSubmitting} loading={isSubmitting}>
|
|
||||||
Send confirmation email
|
|
||||||
</Button>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -40,8 +38,6 @@ const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
|||||||
'This account appears to be using a social login method, please sign in using that method',
|
'This account appears to be using a social login method, please sign in using that method',
|
||||||
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
|
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
|
||||||
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
|
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
|
||||||
[ErrorCode.UNVERIFIED_EMAIL]:
|
|
||||||
'This account has not been verified. Please verify your account before signing in.',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
||||||
@@ -67,7 +63,6 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
@@ -135,17 +130,6 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
|
|
||||||
const errorMessage = ERROR_MESSAGES[result.error];
|
const errorMessage = ERROR_MESSAGES[result.error];
|
||||||
|
|
||||||
if (result.error === ErrorCode.UNVERIFIED_EMAIL) {
|
|
||||||
router.push(`/unverified-account`);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Unable to sign in',
|
|
||||||
description: errorMessage ?? 'An unknown error occurred',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'Unable to sign in',
|
title: 'Unable to sign in',
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -57,7 +55,6 @@ export type SignUpFormProps = {
|
|||||||
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
|
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
@@ -77,13 +74,10 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
try {
|
try {
|
||||||
await signup({ name, email, password, signature });
|
await signup({ name, email, password, signature });
|
||||||
|
|
||||||
router.push(`/unverified-account`);
|
await signIn('credentials', {
|
||||||
|
email,
|
||||||
toast({
|
password,
|
||||||
title: 'Registration Successful',
|
callbackUrl: SIGN_UP_REDIRECT_PATH,
|
||||||
description:
|
|
||||||
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: User Sign Up', {
|
analytics.capture('App: User Sign Up', {
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
|
||||||
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { Switch } from '@documenso/ui/primitives/switch';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { EXPIRATION_DATES } from '../(dashboard)/settings/token/contants';
|
|
||||||
|
|
||||||
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({
|
|
||||||
enabled: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
|
|
||||||
|
|
||||||
export type ApiTokenFormProps = {
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [, copy] = useCopyToClipboard();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState('');
|
|
||||||
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
|
|
||||||
onSuccess(data) {
|
|
||||||
setNewlyCreatedToken(data.token);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<TCreateTokenFormSchema>({
|
|
||||||
resolver: zodResolver(ZCreateTokenFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
tokenName: '',
|
|
||||||
expirationDate: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyToken = async (token: string) => {
|
|
||||||
try {
|
|
||||||
const copied = await copy(token);
|
|
||||||
|
|
||||||
if (!copied) {
|
|
||||||
throw new Error('Unable to copy the token');
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Token copied to clipboard',
|
|
||||||
description: 'The token was copied to your clipboard.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to copy token',
|
|
||||||
description: 'We were unable to copy the token to your clipboard. Please try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
|
|
||||||
try {
|
|
||||||
await createTokenMutation({
|
|
||||||
tokenName,
|
|
||||||
expirationDate: noExpirationDate ? null : expirationDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Token created',
|
|
||||||
description: 'A new token was created successfully.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
|
|
||||||
toast({
|
|
||||||
title: 'An error occurred',
|
|
||||||
description: error.message,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 5000,
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting create the new token. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn(className)}>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
||||||
<fieldset className="mt-6 flex w-full flex-col gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="tokenName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel className="text-muted-foreground">Token name</FormLabel>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<FormControl className="flex-1">
|
|
||||||
<Input type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormDescription className="text-xs italic">
|
|
||||||
Please enter a meaningful name for your token. This will help you identify it
|
|
||||||
later.
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="expirationDate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel className="text-muted-foreground">Token expiration date</FormLabel>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<FormControl className="flex-1">
|
|
||||||
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Choose..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
|
|
||||||
<SelectItem key={key} value={key}>
|
|
||||||
{date}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="">
|
|
||||||
<FormLabel className="text-muted-foreground mt-2">Never expire</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="block md:py-1.5">
|
|
||||||
<Switch
|
|
||||||
className="bg-background"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
setNoExpirationDate((prev) => !prev);
|
|
||||||
field.onChange(val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="hidden md:inline-flex"
|
|
||||||
disabled={!form.formState.isDirty}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
Create token
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="md:hidden">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!form.formState.isDirty}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
Create token
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
{newlyCreatedToken && (
|
|
||||||
<Card className="mt-8" gradient>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
Your token was created successfully! Make sure to copy it because you won't be able to
|
|
||||||
see it again!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="bg-muted-foreground/10 my-4 rounded-md px-2.5 py-1 font-mono text-sm">
|
|
||||||
{newlyCreatedToken}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken)}>
|
|
||||||
Copy token
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* getAssetBuffer is used to retrieve array buffers for various assets
|
* getAssetBuffer is used to retrieve array buffers for various assets
|
||||||
* that are hosted in the `public` folder.
|
* that are hosted in the `public` folder.
|
||||||
@@ -10,7 +8,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|||||||
* @param path The path to the asset, relative to the `public` folder.
|
* @param path The path to the asset, relative to the `public` folder.
|
||||||
*/
|
*/
|
||||||
export const getAssetBuffer = async (path: string) => {
|
export const getAssetBuffer = async (path: string) => {
|
||||||
const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer());
|
return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer());
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { createNextRouter } from '@documenso/api/next';
|
|
||||||
import { ApiContractV1 } from '@documenso/api/v1/contract';
|
|
||||||
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
|
|
||||||
|
|
||||||
const nextRouteHandler = createNextRouter(ApiContractV1, ApiContractV1Implementation, {
|
|
||||||
responseValidation: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
// TODO: Dirty hack to make ts-rest handler work with next.js in a more intuitive way.
|
|
||||||
req.query['ts-rest'] = Array.isArray(req.query['ts-rest']) ? req.query['ts-rest'] : []; // Make `ts-rest` an array.
|
|
||||||
req.query['ts-rest'].unshift('api', 'v1'); // Prepend our base path to the array.
|
|
||||||
|
|
||||||
return await nextRouteHandler(req, res);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials';
|
|
||||||
|
|
||||||
export default testCredentialsHandler;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
||||||
|
|
||||||
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
|
||||||
|
|
||||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
res.status(200).json(OpenAPIV1);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents';
|
|
||||||
|
|
||||||
export default listDocumentsHandler;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { signedDocumentHandler } from '@documenso/lib/server-only/webhooks/zapier/signed-document';
|
|
||||||
|
|
||||||
export default signedDocumentHandler;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe';
|
|
||||||
|
|
||||||
export default subscribeHandler;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
|
|
||||||
|
|
||||||
export default unsubscribeHandler;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/** @type {import('lint-staged').Config} */
|
/** @type {import('lint-staged').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
'**/*.{ts,tsx,cts,mts}': (files) => files.map((file) => `eslint --fix ${file}`),
|
'**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`,
|
||||||
'**/*.{js,jsx,cjs,mjs}': (files) => files.map((file) => `prettier --write ${file}`),
|
'**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`,
|
||||||
'**/*.{yml,mdx}': (files) => files.map((file) => `prettier --write ${file}`),
|
'**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`,
|
||||||
'**/*/package.json': 'npm run precommit',
|
'**/*/package.json': 'npm run precommit',
|
||||||
};
|
};
|
||||||
|
|||||||
1986
package-lock.json
generated
1986
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
|
"dependencies": {},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"next-auth": {
|
"next-auth": {
|
||||||
"next": "14.0.3"
|
"next": "14.0.3"
|
||||||
@@ -54,8 +55,5 @@
|
|||||||
"next-contentlayer": {
|
"next-contentlayer": {
|
||||||
"next": "14.0.3"
|
"next": "14.0.3"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"next-runtime-env": "^3.2.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createNextRouter } from '@ts-rest/next';
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@documenso/api",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "./index.ts",
|
|
||||||
"types": "./index.ts",
|
|
||||||
"license": "MIT",
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint .",
|
|
||||||
"lint:fix": "eslint . --fix",
|
|
||||||
"clean": "rimraf node_modules"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"index.ts",
|
|
||||||
"next.ts",
|
|
||||||
"v1/"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@documenso/lib": "*",
|
|
||||||
"@documenso/prisma": "*",
|
|
||||||
"@ts-rest/core": "^3.30.5",
|
|
||||||
"@ts-rest/next": "^3.30.5",
|
|
||||||
"@ts-rest/open-api": "^3.33.0",
|
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
|
||||||
"luxon": "^3.4.0",
|
|
||||||
"superjson": "^1.13.1",
|
|
||||||
"swagger-ui-react": "^5.11.0",
|
|
||||||
"ts-pattern": "^5.0.5",
|
|
||||||
"zod": "^3.22.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@documenso/tsconfig/react-library.json",
|
|
||||||
"include": ["."],
|
|
||||||
"exclude": ["dist", "build", "node_modules"],
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import SwaggerUI from 'swagger-ui-react';
|
|
||||||
import 'swagger-ui-react/swagger-ui.css';
|
|
||||||
|
|
||||||
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
|
||||||
|
|
||||||
export const OpenApiDocsPage = () => {
|
|
||||||
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
|
|
||||||
};
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { initContract } from '@ts-rest/core';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema,
|
|
||||||
ZAuthorizationHeadersSchema,
|
|
||||||
ZCreateDocumentFromTemplateMutationResponseSchema,
|
|
||||||
ZCreateDocumentFromTemplateMutationSchema,
|
|
||||||
ZCreateDocumentMutationResponseSchema,
|
|
||||||
ZCreateDocumentMutationSchema,
|
|
||||||
ZCreateFieldMutationSchema,
|
|
||||||
ZCreateRecipientMutationSchema,
|
|
||||||
ZDeleteDocumentMutationSchema,
|
|
||||||
ZDeleteFieldMutationSchema,
|
|
||||||
ZDeleteRecipientMutationSchema,
|
|
||||||
ZGetDocumentsQuerySchema,
|
|
||||||
ZSuccessfulDocumentResponseSchema,
|
|
||||||
ZSuccessfulFieldResponseSchema,
|
|
||||||
ZSuccessfulGetDocumentResponseSchema,
|
|
||||||
ZSuccessfulRecipientResponseSchema,
|
|
||||||
ZSuccessfulResponseSchema,
|
|
||||||
ZSuccessfulSigningResponseSchema,
|
|
||||||
ZUnsuccessfulResponseSchema,
|
|
||||||
ZUpdateFieldMutationSchema,
|
|
||||||
ZUpdateRecipientMutationSchema,
|
|
||||||
} from './schema';
|
|
||||||
|
|
||||||
const c = initContract();
|
|
||||||
|
|
||||||
export const ApiContractV1 = c.router(
|
|
||||||
{
|
|
||||||
getDocuments: {
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/v1/documents',
|
|
||||||
query: ZGetDocumentsQuerySchema,
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Get all documents',
|
|
||||||
},
|
|
||||||
|
|
||||||
getDocument: {
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/v1/documents/:id',
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulGetDocumentResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Get a single document',
|
|
||||||
},
|
|
||||||
|
|
||||||
createDocument: {
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/documents',
|
|
||||||
body: ZCreateDocumentMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZCreateDocumentMutationResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Upload a new document and get a presigned URL',
|
|
||||||
},
|
|
||||||
|
|
||||||
createDocumentFromTemplate: {
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/templates/:templateId/create-document',
|
|
||||||
body: ZCreateDocumentFromTemplateMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZCreateDocumentFromTemplateMutationResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Upload a new document and get a presigned URL',
|
|
||||||
},
|
|
||||||
|
|
||||||
sendDocument: {
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/documents/:id/send',
|
|
||||||
body: SendDocumentMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulSigningResponseSchema,
|
|
||||||
400: ZUnsuccessfulResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
500: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Send a document for signing',
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteDocument: {
|
|
||||||
method: 'DELETE',
|
|
||||||
path: '/api/v1/documents/:id',
|
|
||||||
body: ZDeleteDocumentMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulDocumentResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Delete a document',
|
|
||||||
},
|
|
||||||
|
|
||||||
createRecipient: {
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/documents/:id/recipients',
|
|
||||||
body: ZCreateRecipientMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulRecipientResponseSchema,
|
|
||||||
400: ZUnsuccessfulResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
500: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Create a recipient for a document',
|
|
||||||
},
|
|
||||||
|
|
||||||
updateRecipient: {
|
|
||||||
method: 'PATCH',
|
|
||||||
path: '/api/v1/documents/:id/recipients/:recipientId',
|
|
||||||
body: ZUpdateRecipientMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulRecipientResponseSchema,
|
|
||||||
400: ZUnsuccessfulResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
500: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Update a recipient for a document',
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteRecipient: {
|
|
||||||
method: 'DELETE',
|
|
||||||
path: '/api/v1/documents/:id/recipients/:recipientId',
|
|
||||||
body: ZDeleteRecipientMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulRecipientResponseSchema,
|
|
||||||
400: ZUnsuccessfulResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
500: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Delete a recipient from a document',
|
|
||||||
},
|
|
||||||
|
|
||||||
createField: {
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/v1/documents/:id/fields',
|
|
||||||
body: ZCreateFieldMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulFieldResponseSchema,
|
|
||||||
400: ZUnsuccessfulResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
500: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Create a field for a document',
|
|
||||||
},
|
|
||||||
|
|
||||||
updateField: {
|
|
||||||
method: 'PATCH',
|
|
||||||
path: '/api/v1/documents/:id/fields/:fieldId',
|
|
||||||
body: ZUpdateFieldMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulFieldResponseSchema,
|
|
||||||
400: ZUnsuccessfulResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
500: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Update a field for a document',
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteField: {
|
|
||||||
method: 'DELETE',
|
|
||||||
path: '/api/v1/documents/:id/fields/:fieldId',
|
|
||||||
body: ZDeleteFieldMutationSchema,
|
|
||||||
responses: {
|
|
||||||
200: ZSuccessfulFieldResponseSchema,
|
|
||||||
400: ZUnsuccessfulResponseSchema,
|
|
||||||
401: ZUnsuccessfulResponseSchema,
|
|
||||||
404: ZUnsuccessfulResponseSchema,
|
|
||||||
500: ZUnsuccessfulResponseSchema,
|
|
||||||
},
|
|
||||||
summary: 'Delete a field from a document',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
baseHeaders: ZAuthorizationHeadersSchema,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { initClient } from '@ts-rest/core';
|
|
||||||
|
|
||||||
import { ApiContractV1 } from '../contract';
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const client = initClient(ApiContractV1, {
|
|
||||||
baseUrl: 'http://localhost:3000/api/v1',
|
|
||||||
baseHeaders: {
|
|
||||||
authorization: 'Bearer <my-token>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { status, body } = await client.createDocument({
|
|
||||||
body: {
|
|
||||||
title: 'My Document',
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
name: 'John Doe',
|
|
||||||
email: 'john@example.com',
|
|
||||||
role: 'SIGNER',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Jane Doe',
|
|
||||||
email: 'jane@example.com',
|
|
||||||
role: 'APPROVER',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
meta: {
|
|
||||||
subject: 'Please sign this document',
|
|
||||||
message: 'Hey {signer.name}, please sign the following document: {document.name}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status !== 200) {
|
|
||||||
throw new Error('Failed to create document');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { uploadUrl, documentId } = body;
|
|
||||||
|
|
||||||
await fetch(uploadUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
},
|
|
||||||
body: '<raw-binary-data>',
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.sendDocument({
|
|
||||||
params: {
|
|
||||||
id: documentId.toString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { initClient } from '@ts-rest/core';
|
|
||||||
|
|
||||||
import { ApiContractV1 } from '../contract';
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const client = initClient(ApiContractV1, {
|
|
||||||
baseUrl: 'http://localhost:3000/api/v1',
|
|
||||||
baseHeaders: {
|
|
||||||
authorization: 'Bearer <my-token>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentId = '1';
|
|
||||||
const recipientId = 1;
|
|
||||||
|
|
||||||
const { status, body } = await client.createField({
|
|
||||||
params: {
|
|
||||||
id: documentId,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
type: 'SIGNATURE',
|
|
||||||
pageHeight: 2.5, // percent of page to occupy in height
|
|
||||||
pageWidth: 5, // percent of page to occupy in width
|
|
||||||
pageX: 10, // percent from left
|
|
||||||
pageY: 10, // percent from top
|
|
||||||
pageNumber: 1,
|
|
||||||
recipientId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status !== 200) {
|
|
||||||
throw new Error('Failed to create field');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: fieldId } = body;
|
|
||||||
|
|
||||||
console.log(`Field created with id: ${fieldId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { initClient } from '@ts-rest/core';
|
|
||||||
|
|
||||||
import { ApiContractV1 } from '../contract';
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const client = initClient(ApiContractV1, {
|
|
||||||
baseUrl: 'http://localhost:3000/api/v1',
|
|
||||||
baseHeaders: {
|
|
||||||
authorization: 'Bearer <my-token>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentId = '1';
|
|
||||||
const fieldId = '1';
|
|
||||||
|
|
||||||
const { status } = await client.updateField({
|
|
||||||
params: {
|
|
||||||
id: documentId,
|
|
||||||
fieldId,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
type: 'SIGNATURE',
|
|
||||||
pageHeight: 2.5, // percent of page to occupy in height
|
|
||||||
pageWidth: 5, // percent of page to occupy in width
|
|
||||||
pageX: 10, // percent from left
|
|
||||||
pageY: 10, // percent from top
|
|
||||||
pageNumber: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status !== 200) {
|
|
||||||
throw new Error('Failed to update field');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { initClient } from '@ts-rest/core';
|
|
||||||
|
|
||||||
import { ApiContractV1 } from '../contract';
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const client = initClient(ApiContractV1, {
|
|
||||||
baseUrl: 'http://localhost:3000/api/v1',
|
|
||||||
baseHeaders: {
|
|
||||||
authorization: 'Bearer <my-token>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentId = '1';
|
|
||||||
const fieldId = '1';
|
|
||||||
|
|
||||||
const { status } = await client.deleteField({
|
|
||||||
params: {
|
|
||||||
id: documentId,
|
|
||||||
fieldId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status !== 200) {
|
|
||||||
throw new Error('Failed to remove field');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { initClient } from '@ts-rest/core';
|
|
||||||
|
|
||||||
import { ApiContractV1 } from '../contract';
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const client = initClient(ApiContractV1, {
|
|
||||||
baseUrl: 'http://localhost:3000/api/v1',
|
|
||||||
baseHeaders: {
|
|
||||||
authorization: 'Bearer <my-token>',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentId = '1';
|
|
||||||
|
|
||||||
const { status, body } = await client.createRecipient({
|
|
||||||
params: {
|
|
||||||
id: documentId,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
name: 'John Doe',
|
|
||||||
email: 'john@example.com',
|
|
||||||
role: 'APPROVER',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status !== 200) {
|
|
||||||
throw new Error('Failed to add recipient');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: recipientId } = body;
|
|
||||||
|
|
||||||
console.log(`Recipient added with id: ${recipientId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user