mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 08:42:12 +10:00
Merge branch 'main' into fix/layout-shift-on-table
This commit is contained in:
@ -74,6 +74,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
|
||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
|
||||
# OPTIONAL: The private key to use for DKIM signing.
|
||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
||||
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
|
||||
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
|
||||
|
||||
# [[STRIPE]]
|
||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||
|
||||
@ -1,4 +1,16 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||
|
||||
echo "Copying pdf.js"
|
||||
npm run copy:pdfjs --workspace apps/**
|
||||
|
||||
echo "Copying .well-known/ contents"
|
||||
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
||||
|
||||
git add "$MONOREPO_ROOT/apps/web/public/"
|
||||
git add "$MONOREPO_ROOT/apps/marketing/public/"
|
||||
|
||||
npx lint-staged
|
||||
|
||||
7
.well-known/security.txt
Normal file
7
.well-known/security.txt
Normal file
@ -0,0 +1,7 @@
|
||||
# General Issues
|
||||
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
|
||||
|
||||
# Report critical issues privately to let us take appropriate action before publishing.
|
||||
Contact: mailto:security@documenso.com
|
||||
Preferred-Languages: en
|
||||
Canonical: https://documenso.com/.well-known/security.txt
|
||||
@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-01-25
|
||||
Tags:
|
||||
tags:
|
||||
- Vision
|
||||
- Mission
|
||||
- Open Source
|
||||
|
||||
@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-01-10
|
||||
Tags:
|
||||
tags:
|
||||
- GitHub
|
||||
- Backlog
|
||||
- Roadmap
|
||||
|
||||
68
apps/marketing/content/blog/why-i-started-documenso.mdx
Normal file
68
apps/marketing/content/blog/why-i-started-documenso.mdx
Normal file
@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Why I started Documenso
|
||||
description: I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the internet/ world more open.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-02-06
|
||||
tags:
|
||||
- Founders
|
||||
- Mission
|
||||
- Open Source
|
||||
---
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/burgers.jpeg"
|
||||
width="650"
|
||||
height="100"
|
||||
alt="Burgers, drinks on a table between friends."
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
Not the burger from the story. But it could be as well, the place is pretty generic.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
> TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption, and wanted to help make the world/ Internet more open.
|
||||
|
||||
It's hard to pinpoint when I decided to start Documenso. I first uttered the word "Documenso" while sitting in a restaurant with [Felix](https://twitter.com/flxmgdnz), eating a burger and discussing what's next in late 2022. Shortly after, I sat down with a can of caffeine and started building [Documenso 0.9](https://github.com/documenso/documenso/releases/tag/0.9-developer-preview). Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side.
|
||||
|
||||
Looking at the personal side, I've had some time off and was actively looking for my next move. Looking back, I stumbled into my first company. Less so with the second one, but I joined my co-founders and did not develop the core concept myself. While coming up with Documenso, I was deliberately looking for a few things, based on my previous experiences:
|
||||
|
||||
- An entrepreneurial space that was a big enough opportunity
|
||||
- A huge macro trend, lifting everything in it's space
|
||||
- A mode of working that fits my flow (which, luckily for me, is pretty close to the modern startup/ tech scene)
|
||||
- A more significant impact to be made than just earning lots of money (though there is nothing wrong with that)
|
||||
|
||||
Quick shoutout to everyone feeling even a pinch of imposter syndrome while calling themselves a founder. It was after ten years, slightly after starting Documenso, that I started doing it in my head without cringing. So cut yourself some slack. Considering how long I've been doing this, I would have earned the internal title sooner, and so do you. After grappling with my identity for a second, as is customary for founders, my decision to start this journey came quickly.
|
||||
|
||||
Aside from the personal dimension, I had a clear mindset of what I wanted. The criteria I describe below clicked into place one after another, in no particular order. Having experienced no market demand and a very gritty, grindy market, I was looking for something more fundamental. Something basic, infrastructure-like, with a huge demand. A growing market deeply rooted in the ever-increasing digitalization of the world.
|
||||
|
||||
And to be honest, I just always liked digital signature tools. It's a product that is easy enough to comprehend and build but complex and impactful enough to satisfy a hard need. It's a product you can build very product-driven since the market and domain are well understood. So when asked about what's next for me, I literally said, "Digital, um, let's say… signatures". As it turns out, my first gut feeling was spot on, but how spot on I only realized when I started researching the space. An open source document signing company happens to be the perfect intersection of all the criteria and personal preferences I described above; it's pretty amazing, actually:
|
||||
|
||||
- The global signing market is enormous and rapidly growing
|
||||
- To put it bluntly, the signing space is vast and dominated by one outdated player. Outdated in terms of tech, pricing, and ecosystem
|
||||
- The signing space is also ridiculously opaque for a space based on open web tech, open encryption tech, and open signing standards. Even by closed-source standards
|
||||
- We are currently seeing a renaissance for commercial open source startups, combining venture founder financials with open source mechanics
|
||||
- Rebuilding a fundamental infrastructure as open source with a meaningful scale has a profoundly transformative effect on any space
|
||||
- Working in open source requires being open, cooperative, and inclusive. It also requires quite a bit of context jumping, "going with the flow," and empathy
|
||||
- Apart from fixing the signing space, making Documenso successful would be another domino tile toward open source eating the world, which is great for everyone
|
||||
|
||||
Building a company is so complex it can't be planned out. Basing it on great fundamentals and the expected dynamics is the best founders can do, in my humble opinion. After these fundamental decisions, you are (almost) just along for the ride and need to focus on solving the "conventional" problems of starting a company the best you can. With digital signatures hitting so many points of my personal and professional checklist, this already was a great fit. What got me excited at first, though, apart from the perspective of drinking caffeine and coding, was this:
|
||||
|
||||
Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, two years validity, from VeriSign, I think. Apart from it being ridiculously complicated to get, it bothered me that we had basically paid $200 for what is essentially a long number someone generated. SSL wasn't even that widespread back then because it was mainly considered important for e-commerce, no wonder considering it cost so much. "Why would I encrypt a blog?". Fast forward to today, and everyone can get a free SSL cert courtesy of [Let's Encrypt](https://letsencrypt.org/) and browsers are basically blocking unencrypted sites. Mostly, it is even built into hosting platforms, so you barely even notice as a developer.
|
||||
|
||||
I had forgotten all about that story until I realized this is where signing is today. A global need fulfilled only by a closed ecosystem, not really state-of-the-art companies, leading to, let's call it, steep prices. I had considered Let's Encrypt a pillar of the open internet for so long that I forgot that they weren't always there. One day, someone said, let's make the internet better. Signing is another domain that should have had an open ecosystem for a long time. Another parallel to that story is the fact that the cryptographic certificates you need for document signing are also stuck in the "pre-Let's Encrypt world." Free document signing certificates via "Let's Sign" are now another to-do on the [long-term roadmap](https://documen.so/roadmap) list for the open signing ecosystem. Effecting this change in any way is a huge driver for me.
|
||||
|
||||
Apart from my personal gripes with the corporate certificate industry, I have always found encryption fascinating. It's such a fundamental force in society when you think about it: Secure Communication, Secure Commerce, and even [internet native, open source money (Bitcoin)](https://github.com/bitcoin/bitcoin) were created using a bit of smart math. All these examples are expressions of very fundamental human behaviors that should be enabled and protected by open infrastructures.
|
||||
|
||||
I never told rthis to anyone before, but since starting Documenso, I realized that I underestimated the impact and importance of open source for quite some time. When I was in University, I distantly remember my mindset of "yeah, open source is nice, but the great, commercially successful products used in the real world are built by closed companies (aka Microsoft)" _shudder_ It was never really a conscious thought, but enough that I started learning MS Silverlight before plain Javascript. It was slowly, over time, that I realized that open web standards are superior to closed ones, and even later, I understood the same holds true for all software. Open source fixes something in the economy I find hard to articulate. I did my best in [Commodifying Signing](https://documenso.com/blog/commodifying-signing).
|
||||
|
||||
To wrap this up, Documenso happens to be the perfect storm of market opportunity, my interests, and my passions. Creating a company in which people want to work for the long term while tackling these issues is a critical side quest of Documenso. This is not only about building the next generation of signing tech; it's also about doing our part to normalize open, healthy, efficient working cultures and tackling relevant problems.
|
||||
|
||||
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions, comments, thoughts or feelings.
|
||||
|
||||
\
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
7
apps/marketing/public/.well-known/security.txt
Normal file
7
apps/marketing/public/.well-known/security.txt
Normal file
@ -0,0 +1,7 @@
|
||||
# General Issues
|
||||
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
|
||||
|
||||
# Report critical issues privately to let us take appropriate action before publishing.
|
||||
Contact: mailto:security@documenso.com
|
||||
Preferred-Languages: en
|
||||
Canonical: https://documenso.com/.well-known/security.txt
|
||||
BIN
apps/marketing/public/blog/burgers.jpeg
Normal file
BIN
apps/marketing/public/blog/burgers.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
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
@ -12,7 +12,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
|
||||
const document = allDocuments.find((post) => post._raw.flattenedPath === params.content);
|
||||
|
||||
if (!document) {
|
||||
notFound();
|
||||
return { title: 'Not Found' };
|
||||
}
|
||||
|
||||
return { title: document.title };
|
||||
|
||||
@ -7,6 +7,8 @@ import { ChevronLeft } from 'lucide-react';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const generateStaticParams = () =>
|
||||
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
|
||||
|
||||
@ -14,7 +16,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
||||
|
||||
if (!blogPost) {
|
||||
notFound();
|
||||
return {
|
||||
title: 'Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -12,6 +13,8 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { PasswordReveal } from '~/components/(marketing)/password-reveal';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const fontCaveat = Caveat({
|
||||
weight: ['500'],
|
||||
subsets: ['latin'],
|
||||
@ -175,11 +178,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
||||
This is a temporary password. Please change it as soon as possible.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`}
|
||||
target="_blank"
|
||||
className="mt-4 block"
|
||||
>
|
||||
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signin`} target="_blank" className="mt-4 block">
|
||||
<Button size="lg" className="text-base">
|
||||
Let's get started!
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
|
||||
@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-screen-xl flex-1 px-4 lg:px-8">{children}</div>
|
||||
<div className="relative max-w-screen-xl flex-1 px-4 sm:mx-auto lg:px-8">{children}</div>
|
||||
|
||||
<Footer className="bg-background border-muted mt-24 border-t" />
|
||||
</div>
|
||||
|
||||
@ -147,7 +147,12 @@ export default async function OpenPage() {
|
||||
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
|
||||
All our metrics, finances, and learnings are public. We believe in transparency and want
|
||||
to share our journey with you. You can read more about why here:{' '}
|
||||
<a className="font-bold" href="https://documenso.com/blog/pre-seed" target="_blank">
|
||||
<a
|
||||
className="font-bold"
|
||||
href="https://documenso.com/blog/pre-seed"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Announcing Open Metrics
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@ -15,6 +15,8 @@ export const metadata: Metadata = {
|
||||
title: 'Pricing',
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export type PricingPageProps = {
|
||||
searchParams?: {
|
||||
planId?: string;
|
||||
@ -53,7 +55,7 @@ export default function PricingPage() {
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="outline" size="lg" className="rounded-full hover:cursor-pointer" asChild>
|
||||
<Link href="https://github.com/documenso/documenso" target="_blank">
|
||||
<Link href="https://github.com/documenso/documenso" target="_blank" rel="noreferrer">
|
||||
Get Started
|
||||
</Link>
|
||||
</Button>
|
||||
@ -166,6 +168,7 @@ export default function PricingPage() {
|
||||
<Link
|
||||
className="text-documenso-700 font-bold"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="mailto:support@documenso.com"
|
||||
>
|
||||
support@documenso.com
|
||||
@ -175,6 +178,7 @@ export default function PricingPage() {
|
||||
className="text-documenso-700 font-bold"
|
||||
href="https://documen.so/discord"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
in our Discord-Support-Channel
|
||||
</a>{' '}
|
||||
|
||||
@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
@ -85,6 +86,7 @@ export const SinglePlayerClient = () => {
|
||||
setFields(
|
||||
data.fields.map((field, i) => ({
|
||||
id: i,
|
||||
secondaryId: i.toString(),
|
||||
documentId: -1,
|
||||
templateId: null,
|
||||
recipientId: -1,
|
||||
@ -189,7 +191,7 @@ export const SinglePlayerClient = () => {
|
||||
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
|
||||
Create a{' '}
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
|
||||
target="_blank"
|
||||
className="hover:text-foreground/80 font-semibold transition-colors"
|
||||
>
|
||||
|
||||
@ -7,6 +7,7 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export const revalidate = 0;
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// !: This entire file is a hack to get around failed prerendering of
|
||||
// !: the Single Player Mode page. This regression was introduced during
|
||||
|
||||
@ -3,6 +3,7 @@ import { Suspense } from 'react';
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -17,32 +18,35 @@ import './globals.css';
|
||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
template: '%s - Documenso',
|
||||
default: 'Documenso',
|
||||
},
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
keywords:
|
||||
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
|
||||
authors: { name: 'Documenso, Inc.' },
|
||||
robots: 'index, follow',
|
||||
openGraph: {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
export function generateMetadata() {
|
||||
return {
|
||||
title: {
|
||||
template: '%s - Documenso',
|
||||
default: 'Documenso',
|
||||
},
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
type: 'website',
|
||||
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
},
|
||||
};
|
||||
keywords:
|
||||
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
|
||||
authors: { name: 'Documenso, Inc.' },
|
||||
robots: 'index, follow',
|
||||
metadataBase: new URL(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3000'),
|
||||
openGraph: {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
type: 'website',
|
||||
images: ['/opengraph-image.jpg'],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: ['/opengraph-image.jpg'],
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getAllAnonymousFlags();
|
||||
|
||||
@ -8,6 +8,7 @@ import Link from 'next/link';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -82,11 +83,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
</p>
|
||||
|
||||
<Button className="rounded-full text-base" asChild>
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
|
||||
target="_blank"
|
||||
className="mt-6"
|
||||
>
|
||||
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6">
|
||||
Signup Now
|
||||
</Link>
|
||||
</Button>
|
||||
@ -117,13 +114,13 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
</p>
|
||||
|
||||
<Button className="mt-6 rounded-full text-base" asChild>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}>Signup Now</Link>
|
||||
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}>Signup Now</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-8 flex w-full flex-col divide-y">
|
||||
<p className="text-foreground py-4 font-medium">
|
||||
{' '}
|
||||
<a href="https://documenso.com/blog/early-adopters" target="_blank">
|
||||
<a href="https://documenso.com/blog/early-adopters" target="_blank" rel="noreferrer">
|
||||
The Early Adopter Deal:
|
||||
</a>
|
||||
</p>
|
||||
@ -133,7 +130,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
<p className="text-foreground py-4">
|
||||
<strong>
|
||||
{' '}
|
||||
<a href="https://documenso.com/blog/early-adopters" target="_blank">
|
||||
<a
|
||||
href="https://documenso.com/blog/early-adopters"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Includes all upcoming features
|
||||
</a>
|
||||
</strong>
|
||||
|
||||
@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import type { Signature } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
@ -85,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
|
||||
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
|
||||
Create a{' '}
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
|
||||
target="_blank"
|
||||
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
|
||||
>
|
||||
|
||||
@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { usePlausible } from 'next-plausible';
|
||||
import { env } from 'next-runtime-env';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -144,7 +145,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
|
||||
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
|
||||
const planId = env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID');
|
||||
|
||||
if (!planId) {
|
||||
throw new Error('No plan ID found.');
|
||||
}
|
||||
|
||||
const claimPlanInput = signatureDataUrl
|
||||
? {
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
|
||||
import type { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
|
||||
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TClaimPlanResponseSchema, ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
|
||||
import type { TClaimPlanResponseSchema } from '~/api/claim-plan/types';
|
||||
import { ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
@ -40,7 +42,7 @@ export default async function handler(
|
||||
|
||||
if (user) {
|
||||
return res.status(200).json({
|
||||
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`,
|
||||
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/signin`,
|
||||
});
|
||||
}
|
||||
|
||||
@ -77,8 +79,8 @@ export default async function handler(
|
||||
mode: 'subscription',
|
||||
metadata,
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}`,
|
||||
success_url: `${NEXT_PUBLIC_MARKETING_URL()}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${NEXT_PUBLIC_MARKETING_URL()}`,
|
||||
});
|
||||
|
||||
if (!checkout.url) {
|
||||
|
||||
7
apps/web/public/.well-known/security.txt
Normal file
7
apps/web/public/.well-known/security.txt
Normal file
@ -0,0 +1,7 @@
|
||||
# General Issues
|
||||
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
|
||||
|
||||
# Report critical issues privately to let us take appropriate action before publishing.
|
||||
Contact: mailto:security@documenso.com
|
||||
Preferred-Languages: en
|
||||
Canonical: https://documenso.com/.well-known/security.txt
|
||||
BIN
apps/web/public/static/add-user.png
Normal file
BIN
apps/web/public/static/add-user.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
apps/web/public/static/mail-open-alert.png
Normal file
BIN
apps/web/public/static/mail-open-alert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/web/public/static/mail-open.png
Normal file
BIN
apps/web/public/static/mail-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@ -7,9 +7,9 @@ import Link from 'next/link';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { Document, User } from '@documenso/prisma/client';
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document, User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
accessorKey: 'owner',
|
||||
cell: ({ row }) => {
|
||||
const avatarFallbackText = row.original.User.name
|
||||
? recipientInitials(row.original.User.name)
|
||||
? extractInitials(row.original.User.name)
|
||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
|
||||
@ -19,7 +19,7 @@ type ComboboxProps = {
|
||||
onChange: (_values: string[]) => void;
|
||||
};
|
||||
|
||||
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||
const dbRoles = Object.values(Role);
|
||||
@ -79,4 +79,4 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export { MultiSelectCombobox };
|
||||
export { MultiSelectRoleCombobox };
|
||||
@ -18,9 +18,10 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
|
||||
|
||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||
|
||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
<MultiSelectRoleCombobox
|
||||
listValues={roles}
|
||||
onChange={(values: string[]) => onChange(values)}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
||||
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { UsersDataTable } from './data-table-users';
|
||||
import { search } from './fetch-users.actions';
|
||||
@ -18,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
||||
|
||||
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
||||
search(searchString, page, perPage),
|
||||
getPricesByType('individual'),
|
||||
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||
]);
|
||||
|
||||
const individualPriceIds = individualPrices.map((price) => price.id);
|
||||
|
||||
@ -0,0 +1,131 @@
|
||||
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 { 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 { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
export type DocumentPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={user}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
)}
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer
|
||||
document={document}
|
||||
key={documentData.id}
|
||||
documentMeta={documentMeta}
|
||||
documentData={documentData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -32,6 +32,7 @@ export type EditDocumentFormProps = {
|
||||
documentMeta: DocumentMeta | null;
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
documentRootPath: string;
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||
@ -45,6 +46,7 @@ export const EditDocumentForm = ({
|
||||
documentMeta,
|
||||
user: _user,
|
||||
documentData,
|
||||
documentRootPath,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
@ -149,7 +151,7 @@ export const EditDocumentForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message, timezone, dateFormat } = data.meta;
|
||||
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
@ -157,8 +159,9 @@ export const EditDocumentForm = ({
|
||||
meta: {
|
||||
subject,
|
||||
message,
|
||||
timezone,
|
||||
dateFormat,
|
||||
timezone,
|
||||
redirectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
@ -168,7 +171,7 @@ export const EditDocumentForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/documents');
|
||||
router.push(documentRootPath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
@ -1,20 +1,4 @@
|
||||
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 { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { DocumentPageView } from './document-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
@ -22,103 +6,6 @@ export type DocumentPageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
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="/documents" 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>
|
||||
|
||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={user}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer
|
||||
document={document}
|
||||
key={documentData.id}
|
||||
documentMeta={documentMeta}
|
||||
documentData={documentData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
export default function DocumentPage({ params }: DocumentPageProps) {
|
||||
return <DocumentPageView params={params} />;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -39,8 +40,11 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type ResendDocumentActionItemProps = {
|
||||
document: Document;
|
||||
document: Document & {
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
|
||||
export const ResendDocumentActionItem = ({
|
||||
document,
|
||||
recipients,
|
||||
team,
|
||||
}: ResendDocumentActionItemProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isOwner = document.userId === session?.user?.id;
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
|
||||
const isDisabled =
|
||||
!isOwner ||
|
||||
(!isOwner && !isCurrentTeamDocument) ||
|
||||
document.status !== 'PENDING' ||
|
||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||
|
||||
@ -82,7 +88,7 @@ export const ResendDocumentActionItem = ({
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
|
||||
|
||||
toast({
|
||||
title: 'Document re-sent',
|
||||
@ -102,88 +108,86 @@ export const ResendDocumentActionItem = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Resend
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-sm" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent className="sm:max-w-sm" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipients"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
{recipients.map((recipient) => (
|
||||
<FormItem
|
||||
key={recipient.id}
|
||||
className="flex flex-row items-center justify-between gap-x-3"
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipients"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
{recipients.map((recipient) => (
|
||||
<FormItem
|
||||
key={recipient.id}
|
||||
className="flex flex-row items-center justify-between gap-x-3"
|
||||
>
|
||||
<FormLabel
|
||||
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||
'opacity-50': !value.includes(recipient.id),
|
||||
})}
|
||||
>
|
||||
<FormLabel
|
||||
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||
'opacity-50': !value.includes(recipient.id),
|
||||
})}
|
||||
>
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
{recipient.email}
|
||||
</FormLabel>
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
{recipient.email}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
|
||||
checkClassName="text-white"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
checked
|
||||
? onChange([...value, recipient.id])
|
||||
: onChange(value.filter((v) => v !== recipient.id))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
|
||||
checkClassName="text-white"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
checked
|
||||
? onChange([...value, recipient.id])
|
||||
: onChange(value.filter((v) => v !== recipient.id))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||
Send reminder
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</DialogClose>
|
||||
|
||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||
Send reminder
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,7 +7,8 @@ import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
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 type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
@ -18,10 +19,12 @@ export type DataTableActionButtonProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -38,6 +41,9 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const role = recipient?.role;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -46,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
if (!recipient) {
|
||||
document = await trpcClient.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
} else {
|
||||
document = await trpcClient.document.getDocumentByToken.query({
|
||||
@ -81,15 +88,19 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
isCurrentTeamDocument,
|
||||
})
|
||||
.with({ isOwner: true, isDraft: true }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with(
|
||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||
() => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
),
|
||||
)
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
|
||||
@ -20,8 +20,9 @@ import {
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
@ -42,10 +43,12 @@ export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -65,6 +68,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isDocumentDeletable = isOwner;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -73,6 +79,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
if (!recipient) {
|
||||
document = await trpcClient.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
} else {
|
||||
document = await trpcClient.document.getDocumentByToken.query({
|
||||
@ -107,7 +114,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
{recipient?.role !== RecipientRole.CC && (
|
||||
{recipient && recipient?.role !== RecipientRole.CC && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
@ -134,8 +141,8 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
@ -163,7 +170,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
|
||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={row.id}
|
||||
@ -193,6 +200,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
id={row.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
|
||||
type DataTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
|
||||
|
||||
const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
label: member.user.name ?? member.user.email,
|
||||
value: member.user.id,
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: number[]) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="text-muted-foreground font-normal">
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder="Search"
|
||||
loading={!isMounted || isInitialLoading}
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -7,7 +7,7 @@ import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@ -25,11 +25,18 @@ export type DocumentsDataTableProps = {
|
||||
Document & {
|
||||
Recipient: Recipient[];
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
}
|
||||
>;
|
||||
showSenderColumn?: boolean;
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
export const DocumentsDataTable = ({
|
||||
results,
|
||||
showSenderColumn,
|
||||
team,
|
||||
}: DocumentsDataTableProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@ -61,6 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
header: 'Title',
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'sender',
|
||||
header: 'Sender',
|
||||
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
||||
},
|
||||
{
|
||||
header: 'Recipient',
|
||||
accessorKey: 'recipient',
|
||||
@ -80,8 +92,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
(!row.original.deletedAt ||
|
||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DataTableActionButton row={row.original} />
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
<DataTableActionButton team={team} row={row.original} />
|
||||
<DataTableActionDropdown team={team} row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -91,6 +103,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
columnVisibility={{
|
||||
sender: Boolean(showSenderColumn),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
155
apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
Normal file
155
apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team, TeamEmail } from '@documenso/prisma/client';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
import { DataTableSenderFilter } from './data-table-sender-filter';
|
||||
import { EmptyDocumentState } from './empty-state';
|
||||
import { UploadDocument } from './upload-document';
|
||||
|
||||
export type DocumentsPageViewProps = {
|
||||
searchParams?: {
|
||||
status?: ExtendedDocumentStatus;
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
senderIds?: string;
|
||||
};
|
||||
team?: Team & { teamEmail?: TeamEmail | null };
|
||||
};
|
||||
|
||||
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
|
||||
const currentTeam = team ? { id: team.id, url: team.url } : undefined;
|
||||
|
||||
const getStatOptions: GetStatsInput = {
|
||||
user,
|
||||
period,
|
||||
};
|
||||
|
||||
if (team) {
|
||||
getStatOptions.team = {
|
||||
teamId: team.id,
|
||||
teamEmail: team.teamEmail?.email,
|
||||
senderIds,
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await getStats(getStatOptions);
|
||||
|
||||
const results = await findDocuments({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
status,
|
||||
orderBy: {
|
||||
column: 'createdAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
page,
|
||||
perPage,
|
||||
period,
|
||||
senderIds,
|
||||
});
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<UploadDocument team={currentTeam} />
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<div className="flex flex-row items-center">
|
||||
{team && (
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
value={value}
|
||||
asChild
|
||||
>
|
||||
<Link href={getTabHref(value)} scroll={false}>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{Math.min(stats[value], 99)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DataTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{results.count > 0 && (
|
||||
<DocumentsDataTable
|
||||
results={results}
|
||||
showSenderColumn={team !== undefined}
|
||||
team={currentTeam}
|
||||
/>
|
||||
)}
|
||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,5 +1,7 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -16,18 +18,21 @@ type DuplicateDocumentDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DuplicateDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
team,
|
||||
}: DuplicateDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||
id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData
|
||||
@ -37,10 +42,12 @@ export const DuplicateDocumentDialog = ({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
onSuccess: (newId) => {
|
||||
router.push(`/documents/${newId}`);
|
||||
router.push(`${documentsPath}/${newId}`);
|
||||
|
||||
toast({
|
||||
title: 'Document Duplicated',
|
||||
@ -54,7 +61,7 @@ export const DuplicateDocumentDialog = ({
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateDocument({ id });
|
||||
await duplicateDocument({ id, teamId: team?.id });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
|
||||
@ -1,119 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
import { EmptyDocumentState } from './empty-state';
|
||||
import { UploadDocument } from './upload-document';
|
||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||
import { DocumentsPageView } from './documents-page-view';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
status?: ExtendedDocumentStatus;
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
};
|
||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Documents',
|
||||
};
|
||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
|
||||
const stats = await getStats({
|
||||
user,
|
||||
period,
|
||||
});
|
||||
|
||||
const results = await findDocuments({
|
||||
userId: user.id,
|
||||
status,
|
||||
orderBy: {
|
||||
column: 'createdAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
page,
|
||||
perPage,
|
||||
period,
|
||||
});
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
return `/documents?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<UploadDocument />
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
value={value}
|
||||
asChild
|
||||
>
|
||||
<Link href={getTabHref(value)} scroll={false}>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{Math.min(stats[value], 99)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{results.count > 0 && <DocumentsDataTable results={results} />}
|
||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
return <DocumentsPageView searchParams={searchParams} />;
|
||||
}
|
||||
|
||||
@ -10,8 +10,10 @@ import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -20,9 +22,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
team?: {
|
||||
id: number;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
@ -38,13 +44,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
if (remaining.documents === 0) {
|
||||
return 'You have reached your document limit.';
|
||||
return team
|
||||
? 'Document upload disabled due to unpaid invoices'
|
||||
: 'You have reached your document limit.';
|
||||
}
|
||||
|
||||
if (!session?.user.emailVerified) {
|
||||
return 'Verify your email to upload documents.';
|
||||
}
|
||||
}, [remaining.documents, session?.user.emailVerified]);
|
||||
}, [remaining.documents, session?.user.emailVerified, team]);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
@ -60,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -74,7 +83,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
router.push(`/documents/${id}`);
|
||||
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@ -96,21 +105,33 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
toast({
|
||||
title: 'Your document failed to upload.',
|
||||
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone
|
||||
className="min-h-[40vh]"
|
||||
className="h-[min(400px,50vh)]"
|
||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-6 right-0">
|
||||
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
@ -119,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remaining.documents === 0 && (
|
||||
{team?.id === undefined && remaining.documents === 0 && (
|
||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||
|
||||
@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||
@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const [{ user }, teams] = await Promise.all([
|
||||
getRequiredServerComponentSession(),
|
||||
getTeams({ userId: session.user.id }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
<Header user={user} />
|
||||
|
||||
<Header user={user} teams={teams} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
|
||||
@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createBillingPortal } from './create-billing-portal.action';
|
||||
|
||||
export const BillingPortalButton = () => {
|
||||
export type BillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
};
|
||||
|
||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||
@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={async () => handleFetchPortalUrl()}
|
||||
loading={isFetchingPortalUrl}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
export const createBillingPortal = async () => {
|
||||
@ -11,6 +12,6 @@ export const createBillingPortal = async () => {
|
||||
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
|
||||
|
||||
@ -27,13 +28,13 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
|
||||
if (foundSubscription) {
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
}
|
||||
|
||||
return getCheckoutSession({
|
||||
customerId: stripeCustomer.id,
|
||||
priceId,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
};
|
||||
|
||||
@ -5,8 +5,9 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
||||
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
|
||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||
@ -36,23 +37,23 @@ export default async function BillingSettingsPage() {
|
||||
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
||||
}
|
||||
|
||||
const [subscriptions, prices, individualPrices] = await Promise.all([
|
||||
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
|
||||
getSubscriptionsByUserId({ userId: user.id }),
|
||||
getPricesByInterval({ type: 'individual' }),
|
||||
getPricesByType('individual'),
|
||||
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
|
||||
getPrimaryAccountPlanPrices(),
|
||||
]);
|
||||
|
||||
const individualPriceIds = individualPrices.map(({ id }) => id);
|
||||
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
|
||||
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
individualPriceIds.includes(priceId),
|
||||
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
primaryAccountPlanPriceIds.includes(priceId),
|
||||
);
|
||||
|
||||
const subscription =
|
||||
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
individualUserSubscriptions[0];
|
||||
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
primaryAccountPlanSubscriptions[0];
|
||||
|
||||
if (subscription?.priceId) {
|
||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||
|
||||
@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { ProfileForm } from '~/components/forms/profile';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -13,11 +14,7 @@ export default async function ProfileSettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
||||
|
||||
<ProfileForm user={user} className="max-w-xl" />
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
@ -19,13 +20,10 @@ export default async function SecuritySettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Security</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Here you can manage your password and security settings.
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
<SettingsHeader
|
||||
title="Security"
|
||||
subtitle="Here you can manage your password and security settings."
|
||||
/>
|
||||
|
||||
{user.identityProvider === 'DOCUMENSO' ? (
|
||||
<div>
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type AcceptTeamInvitationButtonProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
mutateAsync: acceptTeamInvitation,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
} = trpc.team.acceptTeamInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Accepted team invitation',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description: 'Unable to join this team at this time.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => acceptTeamInvitation({ teamId })}
|
||||
loading={isLoading}
|
||||
disabled={isLoading || isSuccess}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
39
apps/web/src/app/(dashboard)/settings/teams/page.tsx
Normal file
39
apps/web/src/app/(dashboard)/settings/teams/page.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { CreateTeamDialog } from '~/components/(teams)/dialogs/create-team-dialog';
|
||||
import { UserSettingsTeamsPageDataTable } from '~/components/(teams)/tables/user-settings-teams-page-data-table';
|
||||
|
||||
import { TeamEmailUsage } from './team-email-usage';
|
||||
import { TeamInvitations } from './team-invitations';
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Teams" subtitle="Manage all teams you are currently associated with.">
|
||||
<CreateTeamDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<UserSettingsTeamsPageDataTable />
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
<AnimatePresence>
|
||||
{teamEmail && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<TeamEmailUsage teamEmail={teamEmail} />
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<TeamInvitations />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
Normal file
105
apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { TeamEmail } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamEmailUsageProps = {
|
||||
teamEmail: TeamEmail & { team: { name: string; url: string } };
|
||||
};
|
||||
|
||||
export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
|
||||
trpc.team.deleteTeamEmail.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'You have successfully revoked access.',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description:
|
||||
'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
|
||||
<div>
|
||||
<AlertTitle className="mb-0">Team Email</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Your email is currently being used by team{' '}
|
||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
|
||||
).
|
||||
</p>
|
||||
|
||||
<p className="mt-1">They have permission on your behalf to:</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
<li>Display your name and email in documents</li>
|
||||
<li>View all documents sent to your account</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">Revoke access</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
You are about to revoke access for team{' '}
|
||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}) to
|
||||
use your email.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset disabled={isDeletingTeamEmail}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDeletingTeamEmail}
|
||||
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
|
||||
|
||||
export const TeamInvitations = () => {
|
||||
const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{data && data.length > 0 && !isInitialLoading && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<Alert variant="secondary">
|
||||
<div className="flex h-full flex-row items-center p-2">
|
||||
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
You have <strong>{data.length}</strong> pending team invitation
|
||||
{data.length > 1 ? 's' : ''}.
|
||||
</AlertDescription>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
|
||||
View invites
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pending invitations</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
|
||||
{data.map((invitation) => (
|
||||
<li key={invitation.teamId}>
|
||||
<AvatarWithText
|
||||
className="w-full max-w-none py-4"
|
||||
avatarFallback={invitation.team.name.slice(0, 1)}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 font-semibold">
|
||||
{invitation.team.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={formatTeamUrl(invitation.team.url)}
|
||||
rightSideComponent={
|
||||
<div className="ml-auto">
|
||||
<AcceptTeamInvitationButton teamId={invitation.team.id} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Alert>
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@ -28,6 +28,7 @@ export type EditTemplateFormProps = {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
type EditTemplateStep = 'signers' | 'fields';
|
||||
@ -40,6 +41,7 @@ export const EditTemplateForm = ({
|
||||
fields,
|
||||
user: _user,
|
||||
documentData,
|
||||
templateRootPath,
|
||||
}: EditTemplateFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
@ -98,7 +100,7 @@ export const EditTemplateForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/templates');
|
||||
router.push(templateRootPath);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
|
||||
@ -1,81 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { TemplatePageViewProps } from './template-page-view';
|
||||
import { TemplatePageView } from './template-page-view';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
|
||||
export type TemplatePageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { id } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { templateDocumentData } = template;
|
||||
|
||||
const [templateRecipients, templateFields] = await Promise.all([
|
||||
getRecipientsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-8"
|
||||
template={template}
|
||||
user={user}
|
||||
recipients={templateRecipients}
|
||||
fields={templateFields}
|
||||
documentData={templateDocumentData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export default function TemplatePage({ params }: TemplatePageProps) {
|
||||
return <TemplatePageView params={params} />;
|
||||
}
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) => {
|
||||
const { id } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const { templateDocumentData } = template;
|
||||
|
||||
const [templateRecipients, templateFields] = await Promise.all([
|
||||
getRecipientsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-8"
|
||||
template={template}
|
||||
user={user}
|
||||
recipients={templateRecipients}
|
||||
fields={templateFields}
|
||||
documentData={templateDocumentData}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -21,9 +21,15 @@ import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Template;
|
||||
templateRootPath: string;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||
export const DataTableActionDropdown = ({
|
||||
row,
|
||||
templateRootPath,
|
||||
teamId,
|
||||
}: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@ -34,6 +40,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
}
|
||||
|
||||
const isOwner = row.userId === session.user.id;
|
||||
const isTeamTemplate = row.teamId === teamId;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@ -44,20 +51,25 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner} asChild>
|
||||
<Link href={`/templates/${row.id}`}>
|
||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||
<Link href={`${templateRootPath}/${row.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
|
||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDuplicateDialogOpen(true)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@ -65,6 +77,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
|
||||
<DuplicateTemplateDialog
|
||||
id={row.id}
|
||||
teamId={teamId}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
|
||||
@ -28,6 +28,9 @@ type TemplatesDataTableProps = {
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
documentRootPath: string;
|
||||
templateRootPath: string;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const TemplatesDataTable = ({
|
||||
@ -35,6 +38,9 @@ export const TemplatesDataTable = ({
|
||||
perPage,
|
||||
page,
|
||||
totalPages,
|
||||
documentRootPath,
|
||||
templateRootPath,
|
||||
teamId,
|
||||
}: TemplatesDataTableProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
@ -70,7 +76,7 @@ export const TemplatesDataTable = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(`/documents/${id}`);
|
||||
router.push(`${documentRootPath}/${id}`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
@ -83,7 +89,7 @@ export const TemplatesDataTable = ({
|
||||
return (
|
||||
<div className="relative">
|
||||
{remaining.documents === 0 && (
|
||||
<Alert className="mb-4 mt-5">
|
||||
<Alert variant="warning" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Document Limit Exceeded!</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
@ -131,7 +137,12 @@ export const TemplatesDataTable = ({
|
||||
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
||||
Use Template
|
||||
</Button>
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
|
||||
<DataTableActionDropdown
|
||||
row={row.original}
|
||||
teamId={teamId}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@ -35,20 +35,15 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onDeleteTemplate = async () => {
|
||||
try {
|
||||
await deleteTemplate({ id });
|
||||
} catch {
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'This template could not be deleted at this time. Please try again.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
@ -63,20 +58,18 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isLoading}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -14,12 +14,14 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DuplicateTemplateDialogProps = {
|
||||
id: number;
|
||||
teamId?: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DuplicateTemplateDialog = ({
|
||||
id,
|
||||
teamId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DuplicateTemplateDialogProps) => {
|
||||
@ -40,22 +42,15 @@ export const DuplicateTemplateDialog = ({
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while duplicating template.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateTemplate({
|
||||
templateId: id,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while duplicating template.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
@ -66,20 +61,27 @@ export const DuplicateTemplateDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
|
||||
Duplicate
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
onClick={async () =>
|
||||
duplicateTemplate({
|
||||
templateId: id,
|
||||
teamId,
|
||||
})
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -43,8 +43,14 @@ const ZCreateTemplateFormSchema = z.object({
|
||||
|
||||
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
||||
|
||||
export const NewTemplateDialog = () => {
|
||||
type NewTemplateDialogProps = {
|
||||
teamId?: number;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -99,6 +105,7 @@ export const NewTemplateDialog = () => {
|
||||
});
|
||||
|
||||
const { id } = await createTemplate({
|
||||
teamId,
|
||||
title: values.name ? values.name : file.name,
|
||||
templateDocumentDataId,
|
||||
});
|
||||
@ -112,7 +119,7 @@ export const NewTemplateDialog = () => {
|
||||
|
||||
setShowNewTemplateDialog(false);
|
||||
|
||||
void router.push(`/templates/${id}`);
|
||||
router.push(`${templateRootPath}/${id}`);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
|
||||
@ -2,57 +2,17 @@ import React from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
|
||||
|
||||
import { TemplatesDataTable } from './data-table-templates';
|
||||
import { EmptyTemplateState } from './empty-state';
|
||||
import { NewTemplateDialog } from './new-template-dialog';
|
||||
import { TemplatesPageView } from './templates-page-view';
|
||||
import type { TemplatesPageViewProps } from './templates-page-view';
|
||||
|
||||
type TemplatesPageProps = {
|
||||
searchParams?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
searchParams?: TemplatesPageViewProps['searchParams'];
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Templates',
|
||||
};
|
||||
|
||||
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
|
||||
const { templates, totalPages } = await getTemplates({
|
||||
userId: user.id,
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
||||
|
||||
<div>
|
||||
<NewTemplateDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{templates.length > 0 ? (
|
||||
<TemplatesDataTable
|
||||
templates={templates}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
) : (
|
||||
<EmptyTemplateState />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||
return <TemplatesPageView searchParams={searchParams} />;
|
||||
}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
import { TemplatesDataTable } from './data-table-templates';
|
||||
import { EmptyTemplateState } from './empty-state';
|
||||
import { NewTemplateDialog } from './new-template-dialog';
|
||||
|
||||
export type TemplatesPageViewProps = {
|
||||
searchParams?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPageViewProps) => {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 10;
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
|
||||
const { templates, totalPages } = await findTemplates({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
{team && (
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NewTemplateDialog templateRootPath={templateRootPath} teamId={team?.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-5">
|
||||
{templates.length > 0 ? (
|
||||
<TemplatesDataTable
|
||||
templates={templates}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
totalPages={totalPages}
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
) : (
|
||||
<EmptyTemplateState />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,8 @@ import { NextResponse } from 'next/server';
|
||||
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
import type { ShareHandlerAPIResponse } from '~/pages/api/share';
|
||||
|
||||
export const runtime = 'edge';
|
||||
@ -37,7 +39,7 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
|
||||
),
|
||||
]);
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const recipientOrSender: ShareHandlerAPIResponse = await fetch(
|
||||
new URL(`/api/share?slug=${slug}`, baseUrl),
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Metadata } from 'next';
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
type SharePageProps = {
|
||||
params: { slug: string };
|
||||
@ -16,12 +16,12 @@ export function generateMetadata({ params: { slug } }: SharePageProps) {
|
||||
title: 'Documenso - Join the open source signing revolution',
|
||||
description: 'I just signed with Documenso!',
|
||||
type: 'website',
|
||||
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||
images: [`/share/${slug}/opengraph`],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
|
||||
images: [`/share/${slug}/opengraph`],
|
||||
description: 'I just signed with Documenso!',
|
||||
},
|
||||
} satisfies Metadata;
|
||||
@ -35,5 +35,5 @@ export default function SharePage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
redirect(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001');
|
||||
redirect(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001');
|
||||
}
|
||||
|
||||
@ -26,9 +26,10 @@ export type SigningFormProps = {
|
||||
document: Document;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
redirectUrl?: string | null;
|
||||
};
|
||||
|
||||
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
||||
export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
const { data: session } = useSession();
|
||||
@ -74,7 +75,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
router.push(`/sign/${recipient.token}/complete`);
|
||||
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
@ -12,10 +14,16 @@ export type SigningLayoutProps = {
|
||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
let teams: GetTeamsResponse = [];
|
||||
|
||||
if (user && session) {
|
||||
teams = await getTeams({ userId: user.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<div className="min-h-screen">
|
||||
{user && <AuthenticatedHeader user={user} />}
|
||||
{user && <AuthenticatedHeader user={user} teams={teams} />}
|
||||
|
||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||
</div>
|
||||
|
||||
@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
<span className="text-muted-foreground">({recipient.email})</span>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="py-4">
|
||||
<div>
|
||||
<Label htmlFor="signature">Full Name</Label>
|
||||
|
||||
<Input
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
@ -8,12 +9,12 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
@ -40,24 +41,26 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const requestHeaders = Object.fromEntries(headers().entries());
|
||||
|
||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||
|
||||
const [document, fields, recipient] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
viewedDocument({ token }).catch(() => null),
|
||||
viewedDocument({ token, requestMetadata }).catch(() => null),
|
||||
]);
|
||||
|
||||
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData || !recipient) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const { documentData } = document;
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const { user } = await getServerComponentSession();
|
||||
|
||||
@ -65,7 +68,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
document.status === DocumentStatus.COMPLETED ||
|
||||
recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
redirect(`/sign/${token}/complete`);
|
||||
documentMeta?.redirectUrl
|
||||
? redirect(documentMeta.redirectUrl)
|
||||
: redirect(`/sign/${token}/complete`);
|
||||
}
|
||||
|
||||
if (documentMeta?.password) {
|
||||
@ -133,7 +138,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||
<SigningForm document={document} recipient={recipient} fields={fields} />
|
||||
<SigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
20
apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
Normal file
20
apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import { DocumentPageView } from '~/app/(dashboard)/documents/[id]/document-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <DocumentPageView params={params} team={team} />;
|
||||
}
|
||||
25
apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
Normal file
25
apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view';
|
||||
import { DocumentsPageView } from '~/app/(dashboard)/documents/documents-page-view';
|
||||
|
||||
export type TeamsDocumentPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||
};
|
||||
|
||||
export default async function TeamsDocumentPage({
|
||||
params,
|
||||
searchParams = {},
|
||||
}: TeamsDocumentPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <DocumentsPageView searchParams={searchParams} team={team} />;
|
||||
}
|
||||
54
apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
Normal file
54
apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
type ErrorProps = {
|
||||
error: Error & { digest?: string };
|
||||
};
|
||||
|
||||
export default function ErrorPage({ error }: ErrorProps) {
|
||||
const router = useRouter();
|
||||
|
||||
let errorMessage = 'Unknown error';
|
||||
let errorDetails = '';
|
||||
|
||||
if (error.message === AppErrorCode.UNAUTHORIZED) {
|
||||
errorMessage = 'Unauthorized';
|
||||
errorDetails = 'You are not authorized to view this page.';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">{errorMessage}</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">{errorDetails}</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-32"
|
||||
onClick={() => {
|
||||
void router.back();
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/settings/teams">View teams</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
Normal file
130
apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import type { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { type Subscription, SubscriptionStatus } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type LayoutBillingBannerProps = {
|
||||
subscription: Subscription;
|
||||
teamId: number;
|
||||
userRole: TeamMemberRole;
|
||||
};
|
||||
|
||||
export const LayoutBillingBanner = ({
|
||||
subscription,
|
||||
teamId,
|
||||
userRole,
|
||||
}: LayoutBillingBannerProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: createBillingPortal, isLoading } =
|
||||
trpc.team.createBillingPortal.useMutation();
|
||||
|
||||
const handleCreatePortal = async () => {
|
||||
try {
|
||||
const sessionUrl = await createBillingPortal({ teamId });
|
||||
|
||||
window.open(sessionUrl, '_blank');
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description:
|
||||
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (subscription.status === SubscriptionStatus.ACTIVE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn({
|
||||
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400':
|
||||
subscription.status === SubscriptionStatus.PAST_DUE,
|
||||
'bg-destructive text-destructive-foreground':
|
||||
subscription.status === SubscriptionStatus.INACTIVE,
|
||||
})}
|
||||
>
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||
|
||||
{match(subscription.status)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => 'Payment overdue')
|
||||
.with(SubscriptionStatus.INACTIVE, () => 'Teams restricted')
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn({
|
||||
'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500':
|
||||
subscription.status === SubscriptionStatus.PAST_DUE,
|
||||
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
|
||||
subscription.status === SubscriptionStatus.INACTIVE,
|
||||
})}
|
||||
disabled={isLoading}
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={(value) => !isLoading && setIsOpen(value)}>
|
||||
<DialogContent>
|
||||
<DialogTitle>Payment overdue</DialogTitle>
|
||||
|
||||
{match(subscription.status)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => (
|
||||
<DialogDescription>
|
||||
Your payment for teams is overdue. Please settle the payment to avoid any service
|
||||
disruptions.
|
||||
</DialogDescription>
|
||||
))
|
||||
.with(SubscriptionStatus.INACTIVE, () => (
|
||||
<DialogDescription>
|
||||
Due to an unpaid invoice, your team has been restricted. Please settle the payment
|
||||
to restore full access to your team.
|
||||
</DialogDescription>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
{canExecuteTeamAction('MANAGE_BILLING', userRole) && (
|
||||
<DialogFooter>
|
||||
<Button loading={isLoading} onClick={handleCreatePortal}>
|
||||
Resolve payment
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
65
apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
Normal file
65
apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
|
||||
import { RedirectType, redirect } from 'next/navigation';
|
||||
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
import { LayoutBillingBanner } from './layout-billing-banner';
|
||||
|
||||
export type AuthenticatedTeamsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AuthenticatedTeamsLayout({
|
||||
children,
|
||||
params,
|
||||
}: AuthenticatedTeamsLayoutProps) {
|
||||
const { session, user } = await getServerComponentSession();
|
||||
|
||||
if (!session || !user) {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([
|
||||
getTeams({ userId: user.id }),
|
||||
getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl }),
|
||||
]);
|
||||
|
||||
if (getTeamPromise.status === 'rejected') {
|
||||
redirect('/documents', RedirectType.replace);
|
||||
}
|
||||
|
||||
const team = getTeamPromise.value;
|
||||
const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider teamId={team.id}>
|
||||
{team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
|
||||
<LayoutBillingBanner
|
||||
subscription={team.subscription}
|
||||
teamId={team.id}
|
||||
userRole={team.currentTeamMember.role}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Header user={user} teams={teams} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
<RefreshOnFocus />
|
||||
</LimitsProvider>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
Normal file
32
apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">404 Team not found</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
The team you are looking for may have been removed, renamed or may have never existed.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button asChild className="w-32">
|
||||
<Link href="/settings/teams">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { TeamBillingInvoicesDataTable } from '~/components/(teams)/tables/team-billing-invoices-data-table';
|
||||
import { TeamBillingPortalButton } from '~/components/(teams)/team-billing-portal-button';
|
||||
|
||||
export type TeamsSettingsBillingPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl });
|
||||
|
||||
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (team.subscription) {
|
||||
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
||||
}
|
||||
|
||||
const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
|
||||
if (!subscription) {
|
||||
return 'No payment required';
|
||||
}
|
||||
|
||||
const numberOfSeats = subscription.items.data[0].quantity ?? 0;
|
||||
|
||||
const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member';
|
||||
|
||||
const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
|
||||
'LLL dd, yyyy',
|
||||
);
|
||||
|
||||
return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Billing" subtitle="Your subscription is currently active." />
|
||||
|
||||
<Card gradient className="shadow-sm">
|
||||
<CardContent className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-col text-sm">
|
||||
<p className="text-foreground font-semibold">
|
||||
Current plan: {teamSubscription ? 'Team' : 'Community Team'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
{formatTeamSubscriptionDetails(teamSubscription)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{teamSubscription && (
|
||||
<div
|
||||
title={
|
||||
canManageBilling
|
||||
? 'Manage team subscription.'
|
||||
: 'You must be an admin of this team to manage billing.'
|
||||
}
|
||||
>
|
||||
<TeamBillingPortalButton teamId={team.id} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="mt-6">
|
||||
<TeamBillingInvoicesDataTable teamId={team.id} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
Normal file
54
apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav';
|
||||
import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav';
|
||||
|
||||
export type TeamSettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingsLayout({
|
||||
children,
|
||||
params: { teamUrl },
|
||||
}: TeamSettingsLayoutProps) {
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
try {
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||
|
||||
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
|
||||
throw new Error(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = AppError.parseError(e);
|
||||
|
||||
if (error.code === 'P2025') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">Team Settings</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
||||
<MobileNav className="col-span-12 mb-8 md:hidden" />
|
||||
|
||||
<div className="col-span-12 md:col-span-9">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { InviteTeamMembersDialog } from '~/components/(teams)/dialogs/invite-team-member-dialog';
|
||||
import { TeamsMemberPageDataTable } from '~/components/(teams)/tables/teams-member-page-data-table';
|
||||
|
||||
export type TeamsSettingsMembersPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Members" subtitle="Manage the members or invite new members.">
|
||||
<InviteTeamMembersDialog
|
||||
teamId={team.id}
|
||||
currentUserTeamRole={team.currentTeamMember.role}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
|
||||
<TeamsMemberPageDataTable
|
||||
currentUserTeamRole={team.currentTeamMember.role}
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
teamOwnerUserId={team.ownerUserId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
Normal file
186
apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { CheckCircle2, Clock } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email-dialog';
|
||||
import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog';
|
||||
import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog';
|
||||
import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form';
|
||||
|
||||
import { TeamEmailDropdown } from './team-email-dropdown';
|
||||
import { TeamTransferStatus } from './team-transfer-status';
|
||||
|
||||
export type TeamsSettingsPageProps = {
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const session = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
||||
|
||||
const isTransferVerificationExpired =
|
||||
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Team Profile" subtitle="Here you can edit your team's details." />
|
||||
|
||||
<TeamTransferStatus
|
||||
className="mb-4"
|
||||
currentUserTeamRole={team.currentTeamMember.role}
|
||||
teamId={team.id}
|
||||
transferVerification={team.transferVerification}
|
||||
/>
|
||||
|
||||
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||
|
||||
<section className="mt-6 space-y-6">
|
||||
{(team.teamEmail || team.emailVerification) && (
|
||||
<Alert className="p-6" variant="neutral">
|
||||
<AlertTitle>Team email</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
You can view documents associated with this email and use this identity when sending
|
||||
documents.
|
||||
</AlertDescription>
|
||||
|
||||
<hr className="border-border/50 mt-2" />
|
||||
|
||||
<div className="flex flex-row items-center justify-between pt-4">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={extractInitials(
|
||||
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
|
||||
)}
|
||||
primaryText={
|
||||
<span className="text-foreground/80 text-sm font-semibold">
|
||||
{team.teamEmail?.name || team.emailVerification?.name}
|
||||
</span>
|
||||
}
|
||||
secondaryText={
|
||||
<span className="text-sm">
|
||||
{team.teamEmail?.email || team.emailVerification?.email}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center pr-2">
|
||||
<div className="text-muted-foreground mr-4 flex flex-row items-center text-sm xl:mr-8">
|
||||
{match({
|
||||
teamEmail: team.teamEmail,
|
||||
emailVerification: team.emailVerification,
|
||||
})
|
||||
.with({ teamEmail: P.not(null) }, () => (
|
||||
<>
|
||||
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
|
||||
Active
|
||||
</>
|
||||
))
|
||||
.with(
|
||||
{
|
||||
emailVerification: P.when(
|
||||
(emailVerification) =>
|
||||
emailVerification && emailVerification?.expiresAt < new Date(),
|
||||
),
|
||||
},
|
||||
() => (
|
||||
<>
|
||||
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
|
||||
Expired
|
||||
</>
|
||||
),
|
||||
)
|
||||
.with({ emailVerification: P.not(null) }, () => (
|
||||
<>
|
||||
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
|
||||
Awaiting email confirmation
|
||||
</>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</div>
|
||||
|
||||
<TeamEmailDropdown team={team} />
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!team.teamEmail && !team.emailVerification && (
|
||||
<Alert
|
||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Team email</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<ul className="text-muted-foreground mt-0.5 list-inside list-disc text-sm">
|
||||
{/* Feature not available yet. */}
|
||||
{/* <li>Display this name and email when sending documents</li> */}
|
||||
{/* <li>View documents associated with this email</li> */}
|
||||
|
||||
<span>View documents associated with this email</span>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<AddTeamEmailDialog teamId={team.id} />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{team.ownerUserId === session.user.id && (
|
||||
<>
|
||||
{isTransferVerificationExpired && (
|
||||
<Alert
|
||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Transfer team</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
Transfer the ownership of the team to another team member.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<TransferTeamDialog
|
||||
ownerUserId={team.ownerUserId}
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Delete team</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
This team, and any associated data excluding billing invoices will be permanently
|
||||
deleted.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<DeleteTeamDialog teamId={team.id} teamName={team.name} />
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
|
||||
|
||||
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { UpdateTeamEmailDialog } from '~/components/(teams)/dialogs/update-team-email-dialog';
|
||||
|
||||
export type TeamsSettingsPageProps = {
|
||||
team: Awaited<ReturnType<typeof getTeamByUrl>>;
|
||||
};
|
||||
|
||||
export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } =
|
||||
trpc.team.resendTeamEmailVerification.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Email verification has been resent',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description: 'Unable to resend verification at this time. Please try again.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
|
||||
trpc.team.deleteTeamEmail.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Team email has been removed',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description: 'Unable to remove team email at this time. Please try again.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } =
|
||||
trpc.team.deleteTeamEmailVerification.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Email verification has been removed',
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
description: 'Unable to remove email verification at this time. Please try again.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onRemove = async () => {
|
||||
if (team.teamEmail) {
|
||||
await deleteTeamEmail({ teamId: team.id });
|
||||
}
|
||||
|
||||
if (team.emailVerification) {
|
||||
await deleteTeamEmailVerification({ teamId: team.id });
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
{!team.teamEmail && team.emailVerification && (
|
||||
<DropdownMenuItem
|
||||
disabled={isResendingEmailVerification}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void resendEmailVerification({ teamId: team.id });
|
||||
}}
|
||||
>
|
||||
{isResendingEmailVerification ? (
|
||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Resend verification
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{team.teamEmail && (
|
||||
<UpdateTeamEmailDialog
|
||||
teamEmail={team.teamEmail}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isDeletingTeamEmail || isDeletingTeamEmailVerification}
|
||||
onClick={async () => onRemove()}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client';
|
||||
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';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamTransferStatusProps = {
|
||||
className?: string;
|
||||
currentUserTeamRole: TeamMemberRole;
|
||||
teamId: number;
|
||||
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
|
||||
};
|
||||
|
||||
export const TeamTransferStatus = ({
|
||||
className,
|
||||
currentUserTeamRole,
|
||||
teamId,
|
||||
transferVerification,
|
||||
}: TeamTransferStatusProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
|
||||
|
||||
const { mutateAsync: deleteTeamTransferRequest, isLoading } =
|
||||
trpc.team.deleteTeamTransferRequest.useMutation({
|
||||
onSuccess: () => {
|
||||
if (!isExpired) {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'The team transfer invitation has been successfully deleted.',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{transferVerification && (
|
||||
<AnimateGenericFadeInOut>
|
||||
<Alert
|
||||
variant={isExpired ? 'destructive' : 'warning'}
|
||||
className={cn(
|
||||
'flex flex-col justify-between p-6 sm:flex-row sm:items-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<AlertTitle>
|
||||
{isExpired ? 'Team transfer request expired' : 'Team transfer in progress'}
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
{isExpired ? (
|
||||
<p className="text-sm">
|
||||
The team transfer request to <strong>{transferVerification.name}</strong> has
|
||||
expired.
|
||||
</p>
|
||||
) : (
|
||||
<section className="text-sm">
|
||||
<p>
|
||||
A request to transfer the ownership of this team has been sent to{' '}
|
||||
<strong>
|
||||
{transferVerification.name} ({transferVerification.email})
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If they accept this request, the team will be transferred to their account.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
{canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && (
|
||||
<Button
|
||||
onClick={async () => deleteTeamTransferRequest({ teamId })}
|
||||
loading={isLoading}
|
||||
variant={isExpired ? 'destructive' : 'ghost'}
|
||||
className={cn('ml-auto', {
|
||||
'hover:bg-transparent hover:text-blue-800': !isExpired,
|
||||
})}
|
||||
>
|
||||
{isExpired ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
22
apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
Normal file
22
apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { TemplatePageViewProps } from '~/app/(dashboard)/templates/[id]/template-page-view';
|
||||
import { TemplatePageView } from '~/app/(dashboard)/templates/[id]/template-page-view';
|
||||
|
||||
type TeamTemplatePageProps = {
|
||||
params: TemplatePageViewProps['params'] & {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <TemplatePageView params={params} team={team} />;
|
||||
}
|
||||
26
apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
Normal file
26
apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { TemplatesPageViewProps } from '~/app/(dashboard)/templates/templates-page-view';
|
||||
import { TemplatesPageView } from '~/app/(dashboard)/templates/templates-page-view';
|
||||
|
||||
type TeamTemplatesPageProps = {
|
||||
searchParams?: TemplatesPageViewProps['searchParams'];
|
||||
params: {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamTemplatesPage({
|
||||
searchParams = {},
|
||||
params,
|
||||
}: TeamTemplatesPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <TemplatesPageView searchParams={searchParams} team={team} />;
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
|
||||
@ -9,7 +13,22 @@ export const metadata: Metadata = {
|
||||
title: 'Sign In',
|
||||
};
|
||||
|
||||
export default function SignInPage() {
|
||||
type SignInPageProps = {
|
||||
searchParams: {
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||
|
||||
if (!email && rawEmail) {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
@ -18,9 +37,13 @@ export default function SignInPage() {
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
|
||||
<SignInForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||
<SignInForm
|
||||
className="mt-4"
|
||||
initialEmail={email || undefined}
|
||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||
/>
|
||||
|
||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
|
||||
@ -2,7 +2,10 @@ import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
|
||||
@ -10,11 +13,26 @@ export const metadata: Metadata = {
|
||||
title: 'Sign Up',
|
||||
};
|
||||
|
||||
export default function SignUpPage() {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
type SignUpPageProps = {
|
||||
searchParams: {
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||
|
||||
if (!email && rawEmail) {
|
||||
redirect('/signup');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||
@ -24,7 +42,11 @@ export default function SignUpPage() {
|
||||
signing is within your grasp.
|
||||
</p>
|
||||
|
||||
<SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||
<SignUpForm
|
||||
className="mt-4"
|
||||
initialEmail={email || undefined}
|
||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
|
||||
121
apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
Normal file
121
apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
type AcceptInvitationPageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AcceptInvitationPage({
|
||||
params: { token },
|
||||
}: AcceptInvitationPageProps) {
|
||||
const session = await getServerComponentSession();
|
||||
|
||||
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamMemberInvite) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
This token is invalid or has expired. Please contact your team for a new invitation.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const team = await getTeamById({ teamId: teamMemberInvite.teamId });
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: teamMemberInvite.email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Directly convert the team member invite to a team member if they already have an account.
|
||||
if (user) {
|
||||
await acceptTeamInvitation({ userId: user.id, teamId: team.id });
|
||||
}
|
||||
|
||||
// For users who do not exist yet, set the team invite status to accepted, which is checked during
|
||||
// user creation to determine if we should add the user to the team at that time.
|
||||
if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.ACCEPTED) {
|
||||
await prisma.teamMemberInvite.update({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: TeamMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const email = encryptSecondaryData({
|
||||
data: teamMemberInvite.email,
|
||||
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team invitation</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
You have been invited by <strong>{team.name}</strong> to join their team.
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-1 text-sm">
|
||||
To accept this invitation you must create an account.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href={`/signup?email=${encodeURIComponent(email)}`}>Create account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invitation accepted!</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
You have accepted an invitation from <strong>{team.name}</strong> to join their team.
|
||||
</p>
|
||||
|
||||
{isSessionUserTheInvitedUser ? (
|
||||
<Button asChild>
|
||||
<Link href="/">Continue</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={`/signin?email=${encodeURIComponent(email)}`}>Continue to login</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
type VerifyTeamEmailPageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) {
|
||||
const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
This link is invalid or has expired. Please contact your team to resend a verification.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { team } = teamEmailVerification;
|
||||
|
||||
let isTeamEmailVerificationError = false;
|
||||
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.teamEmailVerification.deleteMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
}),
|
||||
prisma.teamEmail.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
email: teamEmailVerification.email,
|
||||
name: teamEmailVerification.name,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
isTeamEmailVerificationError = true;
|
||||
}
|
||||
|
||||
if (isTeamEmailVerificationError) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team email verification</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Something went wrong while attempting to verify your email address for{' '}
|
||||
<strong>{team.name}</strong>. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team email verified!</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
You have verified your email address for <strong>{team.name}</strong>.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Continue</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership';
|
||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
type VerifyTeamTransferPage = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function VerifyTeamTransferPage({
|
||||
params: { token },
|
||||
}: VerifyTeamTransferPage) {
|
||||
const teamTransferVerification = await prisma.teamTransferVerification.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
This link is invalid or has expired. Please contact your team to resend a transfer
|
||||
request.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/">Return</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { team } = teamTransferVerification;
|
||||
|
||||
let isTransferError = false;
|
||||
|
||||
try {
|
||||
await transferTeamOwnership({ token });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
isTransferError = true;
|
||||
}
|
||||
|
||||
if (isTransferError) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team ownership transfer</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Something went wrong while attempting to transfer the ownership of team{' '}
|
||||
<strong>{team.name}</strong> to your. Please try again later or contact support.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Team ownership transferred!</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
The ownership of team <strong>{team.name}</strong> has been successfully transferred to you.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href={`/t/${team.url}/settings`}>Continue</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,11 @@ import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
@ -19,32 +22,35 @@ import './globals.css';
|
||||
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||
|
||||
export const metadata = {
|
||||
title: {
|
||||
template: '%s - Documenso',
|
||||
default: 'Documenso',
|
||||
},
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
keywords:
|
||||
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
|
||||
authors: { name: 'Documenso, Inc.' },
|
||||
robots: 'index, follow',
|
||||
openGraph: {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
export function generateMetadata() {
|
||||
return {
|
||||
title: {
|
||||
template: '%s - Documenso',
|
||||
default: 'Documenso',
|
||||
},
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
type: 'website',
|
||||
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
},
|
||||
};
|
||||
keywords:
|
||||
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
|
||||
authors: { name: 'Documenso, Inc.' },
|
||||
robots: 'index, follow',
|
||||
metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
|
||||
openGraph: {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
type: 'website',
|
||||
images: ['/opengraph-image.jpg'],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: ['/opengraph-image.jpg'],
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const flags = await getServerComponentAllFlags();
|
||||
@ -62,6 +68,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
|
||||
<Suspense>
|
||||
|
||||
@ -4,6 +4,7 @@ import React from 'react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
@ -25,7 +26,7 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The signing link has been copied to your clipboard.',
|
||||
|
||||
@ -197,20 +197,22 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
)}
|
||||
{!currentPage && (
|
||||
<>
|
||||
<CommandGroup heading="Documents">
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Documents">
|
||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Templates">
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Templates">
|
||||
<Commands push={push} pages={TEMPLATES_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Settings">
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Settings">
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Preferences">
|
||||
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Preferences">
|
||||
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('theme')}>
|
||||
Change theme
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
{searchResults.length > 0 && (
|
||||
<CommandGroup heading="Your documents">
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Your documents">
|
||||
<Commands push={push} pages={searchResults} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
@ -231,6 +233,7 @@ const Commands = ({
|
||||
}) => {
|
||||
return pages.map((page, idx) => (
|
||||
<CommandItem
|
||||
className="-mx-2 -my-1 rounded-lg"
|
||||
key={page.path + idx}
|
||||
value={page.value ?? page.label}
|
||||
onSelect={() => push(page.path)}
|
||||
@ -255,7 +258,7 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
|
||||
<CommandItem
|
||||
key={theme.theme}
|
||||
onSelect={() => setTheme(theme.theme)}
|
||||
className="mx-2 first:mt-2 last:mb-2"
|
||||
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
|
||||
>
|
||||
<theme.icon className="mr-2" />
|
||||
{theme.label}
|
||||
|
||||
@ -4,10 +4,11 @@ import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -28,10 +29,13 @@ export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||
|
||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||
|
||||
useEffect(() => {
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
||||
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
|
||||
@ -51,11 +55,13 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
{navigationLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
href={`${rootHref}${href}`}
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
|
||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
|
||||
`${rootHref}${href}`,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
|
||||
@ -1,23 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { MenuIcon, SearchIcon } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
|
||||
import { CommandMenu } from '../common/command-menu';
|
||||
import { DesktopNav } from './desktop-nav';
|
||||
import { MenuSwitcher } from './menu-switcher';
|
||||
import { MobileNavigation } from './mobile-navigation';
|
||||
import { ProfileDropdown } from './profile-dropdown';
|
||||
|
||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
user: User;
|
||||
teams: GetTeamsResponse;
|
||||
};
|
||||
|
||||
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
const params = useParams();
|
||||
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isTeamsEnabled = getFlag('app_teams');
|
||||
|
||||
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@ -30,6 +47,34 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
if (!isTeamsEnabled) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
scrollY > 5 && 'border-b-border',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Logo className="h-6 w-auto" />
|
||||
</Link>
|
||||
|
||||
<DesktopNav />
|
||||
|
||||
<div className="flex gap-x-4 md:ml-8">
|
||||
<ProfileDropdown user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@ -41,8 +86,8 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
href={getRootHref(params)}
|
||||
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
||||
>
|
||||
<Logo className="h-6 w-auto" />
|
||||
</Link>
|
||||
@ -50,11 +95,24 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
<DesktopNav />
|
||||
|
||||
<div className="flex gap-x-4 md:ml-8">
|
||||
<ProfileDropdown user={user} />
|
||||
<MenuSwitcher user={user} teams={teams} />
|
||||
</div>
|
||||
|
||||
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button> */}
|
||||
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||
<button onClick={() => setIsCommandMenuOpen(true)}>
|
||||
<SearchIcon className="text-muted-foreground h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<button onClick={() => setIsHamburgerMenuOpen(true)}>
|
||||
<MenuIcon className="text-muted-foreground h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<CommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
|
||||
|
||||
<MobileNavigation
|
||||
isMenuOpen={isHamburgerMenuOpen}
|
||||
onMenuOpenChange={setIsHamburgerMenuOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
230
apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
Normal file
230
apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
export type MenuSwitcherProps = {
|
||||
user: User;
|
||||
teams: GetTeamsResponse;
|
||||
};
|
||||
|
||||
export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
|
||||
initialData: initialTeamsData,
|
||||
});
|
||||
|
||||
const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
|
||||
|
||||
const isPathTeamUrl = (teamUrl: string) => {
|
||||
if (!pathname || !pathname.startsWith(`/t/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathname.split('/')[2] === teamUrl;
|
||||
};
|
||||
|
||||
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
|
||||
|
||||
const formatAvatarFallback = (teamName?: string) => {
|
||||
if (teamName !== undefined) {
|
||||
return teamName.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
||||
};
|
||||
|
||||
const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
|
||||
if (!team) {
|
||||
return 'Personal Account';
|
||||
}
|
||||
|
||||
if (team.ownerUserId === user.id) {
|
||||
return 'Owner';
|
||||
}
|
||||
|
||||
return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role];
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the redirect URL so we can switch between documents and templates page
|
||||
* seemlessly between teams and personal accounts.
|
||||
*/
|
||||
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
|
||||
const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/';
|
||||
|
||||
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
|
||||
|
||||
if (currentPathname === '/templates') {
|
||||
return `${baseUrl}templates`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="menu-switcher"
|
||||
variant="none"
|
||||
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus-visible:border-0 focus-visible:ring-0"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||
primaryText={selectedTeam ? selectedTeam.name : user.name}
|
||||
secondaryText={formatSecondaryAvatarText(selectedTeam)}
|
||||
rightSideComponent={
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
{teams ? (
|
||||
<>
|
||||
<DropdownMenuLabel>Personal</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={formatRedirectUrlOnSwitch()}>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback()}
|
||||
primaryText={user.name}
|
||||
secondaryText={formatSecondaryAvatarText()}
|
||||
rightSideComponent={
|
||||
!pathname?.startsWith(`/t/`) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator className="mt-2" />
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<p>Teams</p>
|
||||
|
||||
<div className="flex flex-row space-x-2">
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
title="Manage teams"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
||||
asChild
|
||||
>
|
||||
<Link href="/settings/teams">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
title="Create team"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
||||
asChild
|
||||
>
|
||||
<Link href="/settings/teams?action=add-team">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{teams.map((team) => (
|
||||
<DropdownMenuItem asChild key={team.id}>
|
||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(team.name)}
|
||||
primaryText={team.name}
|
||||
secondaryText={formatSecondaryAvatarText(team)}
|
||||
rightSideComponent={
|
||||
isPathTeamUrl(team.url) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link
|
||||
href="/settings/teams?action=add-team"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
Create team
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isUserAdmin && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link href="/admin">Admin panel</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link href="/settings/profile">User settings</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{selectedTeam &&
|
||||
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link href={`/t/${selectedTeam.url}/settings/`}>Team settings</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-destructive/90 hover:!text-destructive px-4 py-2"
|
||||
onSelect={async () =>
|
||||
signOut({
|
||||
callbackUrl: '/',
|
||||
})
|
||||
}
|
||||
>
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
export type MobileNavigationProps = {
|
||||
isMenuOpen: boolean;
|
||||
onMenuOpenChange?: (_value: boolean) => void;
|
||||
};
|
||||
|
||||
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||
const params = useParams();
|
||||
|
||||
const handleMenuItemClick = () => {
|
||||
onMenuOpenChange?.(false);
|
||||
};
|
||||
|
||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||
|
||||
const menuNavigationLinks = [
|
||||
{
|
||||
href: `${rootHref}/documents`,
|
||||
text: 'Documents',
|
||||
},
|
||||
{
|
||||
href: `${rootHref}/templates`,
|
||||
text: 'Templates',
|
||||
},
|
||||
{
|
||||
href: '/settings/teams',
|
||||
text: 'Teams',
|
||||
},
|
||||
{
|
||||
href: '/settings/profile',
|
||||
text: 'Settings',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
||||
<Link href="/" onClick={handleMenuItemClick}>
|
||||
<Image
|
||||
src={LogoImage}
|
||||
alt="Documenso Logo"
|
||||
className="dark:invert"
|
||||
width={170}
|
||||
height={25}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="mt-8 flex w-full flex-col items-start gap-y-4">
|
||||
{menuNavigationLinks.map(({ href, text }) => (
|
||||
<Link
|
||||
key={href}
|
||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||
href={href}
|
||||
onClick={() => handleMenuItemClick()}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||
onClick={async () =>
|
||||
signOut({
|
||||
callbackUrl: '/',
|
||||
})
|
||||
}
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex w-full flex-col space-y-4 self-end">
|
||||
<div className="w-fit">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@ -20,7 +20,7 @@ import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -51,7 +51,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
const avatarFallback = user.name
|
||||
? recipientInitials(user.name)
|
||||
? extractInitials(user.name)
|
||||
: user.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
|
||||
@ -21,9 +21,9 @@ export const PeriodSelector = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const period = useMemo(() => {
|
||||
const p = searchParams?.get('period') ?? '';
|
||||
const p = searchParams?.get('period') ?? 'all';
|
||||
|
||||
return isPeriodSelectorValue(p) ? p : '';
|
||||
return isPeriodSelectorValue(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onPeriodChange = (newPeriod: string) => {
|
||||
@ -35,7 +35,7 @@ export const PeriodSelector = () => {
|
||||
|
||||
params.set('period', newPeriod);
|
||||
|
||||
if (newPeriod === '') {
|
||||
if (newPeriod === '' || newPeriod === 'all') {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ export const PeriodSelector = () => {
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="">All Time</SelectItem>
|
||||
<SelectItem value="all">All Time</SelectItem>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="14d">Last 14 days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -19,6 +19,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const { getFlag } = useFeatureFlags();
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
const isTeamsEnabled = getFlag('app_teams');
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
@ -35,6 +36,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isTeamsEnabled && (
|
||||
<Link href="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Teams
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link href="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user