Compare commits

..

2 Commits

332 changed files with 1741 additions and 17217 deletions

View File

@ -5,9 +5,9 @@ NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
# Application Key for symmetric encryption and decryption
# REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
NEXT_PRIVATE_ENCRYPTION_KEY=""
# REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=""
# [[AUTH OPTIONAL]]
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
@ -25,7 +25,7 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
@ -74,8 +74,6 @@ 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=

View File

@ -27,16 +27,14 @@ Tags:
# Commodifying Signing
> TLDR; We are creating signing as a public good and are commoditizing it to make it cheaper and better.
While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means.
> While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means.
Let's start with why we are doing this. Documenso's mission is to solve the domain of signing once and for all for everyone. In so many calls, I hear stories about how organizations build their own solution because the existing ones are too expensive or need to be more flexible. That means not hundreds but probably thousands of companies worldwide have done the same. This is simply wasting humanity's time. Since digital signing systems are understood well enough that seemingly "everyone" can build them, given enough pain, It's time to do it once correctly.
## Is signing already a commodity?
> In economics, a **commodity** is an economic good, usually a resource, that has explicitly full or substantial fungibility: that is, the market treats instances of the good as equivalent or nearly so with no regard to who produced them.
That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market?
> That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market?
- Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume.
- Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to
@ -70,8 +68,7 @@ Before creating a commodity, we are raising the bar of what the underlying publi
As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I:
> In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers.
By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish.
> By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish.
## Changing the Game

View File

@ -109,7 +109,7 @@ It's similar to the Kanban board for the development backlog.
While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead.
We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live).
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
Best from Hamburg\
Timur

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

View File

@ -15,7 +15,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
notFound();
}
return { title: document.title };
return { title: `Documenso - ${document.title}` };
};
const mdxComponents: MDXComponents = {

View File

@ -18,9 +18,7 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
}
return {
title: {
absolute: `${blogPost.title} - Documenso Blog`,
},
title: `Documenso - ${blogPost.title}`,
description: blogPost.description,
};
};

View File

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

View File

@ -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 max-w-screen-xl flex-1 px-4 sm:mx-auto lg:px-8">{children}</div>
<div className="relative mx-auto max-w-screen-xl flex-1 px-4 lg:px-8">{children}</div>
<Footer className="bg-background border-muted mt-24 border-t" />
</div>

View File

@ -1,5 +1,3 @@
import type { Metadata } from 'next';
import { z } from 'zod';
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
@ -16,10 +14,6 @@ import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
import { TeamMembers } from './team-members';
import { OpenPageTooltip } from './tooltip';
export const metadata: Metadata = {
title: 'Open Startup',
};
export const revalidate = 3600;
export const dynamic = 'force-dynamic';

View File

@ -1,4 +1,3 @@
import type { Metadata } from 'next';
import Image from 'next/image';
import { z } from 'zod';
@ -6,12 +5,7 @@ import { z } from 'zod';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { OSSFriendsContainer } from './container';
import type { TOSSFriendsSchema } from './schema';
import { ZOSSFriendsSchema } from './schema';
export const metadata: Metadata = {
title: 'OSS Friends',
};
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';
export default async function OSSFriendsPage() {
const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', {

View File

@ -1,5 +1,4 @@
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
import type { Metadata } from 'next';
import { Caveat } from 'next/font/google';
import { cn } from '@documenso/ui/lib/utils';
@ -11,11 +10,6 @@ import { OpenBuildTemplateBento } from '~/components/(marketing)/open-build-temp
import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento';
export const revalidate = 600;
export const metadata: Metadata = {
title: {
absolute: 'Documenso - The Open Source DocuSign Alternative',
},
};
const fontCaveat = Caveat({
weight: ['500'],

View File

@ -1,4 +1,5 @@
import type { Metadata } from 'next';
'use client';
import Link from 'next/link';
import {
@ -11,10 +12,6 @@ import { Button } from '@documenso/ui/primitives/button';
import { PricingTable } from '~/components/(marketing)/pricing-table';
export const metadata: Metadata = {
title: 'Pricing',
};
export type PricingPageProps = {
searchParams?: {
planId?: string;

View File

@ -85,7 +85,6 @@ export const SinglePlayerClient = () => {
setFields(
data.fields.map((field, i) => ({
id: i,
secondaryId: i.toString(),
documentId: -1,
templateId: null,
recipientId: -1,
@ -159,7 +158,6 @@ export const SinglePlayerClient = () => {
readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
role: 'SIGNER',
};
const onFileDrop = async (file: File) => {

View File

@ -1,11 +1,5 @@
import type { Metadata } from 'next';
import { SinglePlayerClient } from './client';
export const metadata: Metadata = {
title: 'Singleplayer',
};
export const revalidate = 0;
// !: This entire file is a hack to get around failed prerendering of

View File

@ -18,10 +18,7 @@ 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',
},
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:

View File

@ -399,7 +399,6 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
</DialogDescription>
<SignaturePad
disabled={isSubmitting}
className="aspect-video w-full rounded-md border"
defaultValue={signatureDataUrl || ''}
onChange={setDraftSignatureDataUrl}

View File

@ -45,7 +45,6 @@
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",
"zod": "^3.22.4"
},
@ -54,8 +53,7 @@
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
"@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39"
"@types/react-dom": "18.2.7"
},
"overrides": {
"next-auth": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -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 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 { FindResultSet } from '@documenso/lib/types/find-result-set';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { 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
? extractInitials(row.original.User.name)
? recipientInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (

View File

@ -18,10 +18,9 @@ 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>;
@ -118,7 +117,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl>
<MultiSelectRoleCombobox
<MultiSelectCombobox
listValues={roles}
onChange={(values: string[]) => onChange(values)}
/>

View File

@ -1,5 +1,4 @@
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { UsersDataTable } from './data-table-users';
import { search } from './fetch-users.actions';
@ -19,7 +18,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
getPricesByType('individual'),
]);
const individualPriceIds = individualPrices.map((price) => price.id);

View File

@ -1,131 +0,0 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { 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>
);
};

View File

@ -32,7 +32,6 @@ export type EditDocumentFormProps = {
documentMeta: DocumentMeta | null;
fields: Field[];
documentData: DocumentData;
documentRootPath: string;
};
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
@ -46,7 +45,6 @@ export const EditDocumentForm = ({
documentMeta,
user: _user,
documentData,
documentRootPath,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
@ -151,7 +149,7 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
const { subject, message, timezone, dateFormat } = data.meta;
try {
await sendDocument({
@ -159,9 +157,8 @@ export const EditDocumentForm = ({
meta: {
subject,
message,
dateFormat,
timezone,
redirectUrl,
dateFormat,
},
});
@ -171,7 +168,7 @@ export const EditDocumentForm = ({
duration: 5000,
});
router.push(documentRootPath);
router.push('/documents');
} catch (err) {
console.error(err);
@ -221,9 +218,9 @@ export const EditDocumentForm = ({
<AddTitleFormPartial
key={recipients.length}
documentFlow={documentFlow.title}
document={document}
recipients={recipients}
fields={fields}
document={document}
onSubmit={onAddTitleFormSubmit}
/>

View File

@ -1,4 +1,20 @@
import { DocumentPageView } from './document-page-view';
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';
export type DocumentPageProps = {
params: {
@ -6,6 +22,103 @@ export type DocumentPageProps = {
};
};
export default function DocumentPage({ params }: DocumentPageProps) {
return <DocumentPageView params={params} />;
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>
);
}

View File

@ -10,7 +10,6 @@ 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';
@ -40,11 +39,8 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
document: Document & {
team: Pick<Team, 'id' | 'url'> | null;
};
document: Document;
recipients: Recipient[];
team?: Pick<Team, 'id' | 'url'>;
};
export const ZResendDocumentFormSchema = z.object({
@ -58,17 +54,15 @@ 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 && !isCurrentTeamDocument) ||
!isOwner ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
@ -88,7 +82,7 @@ export const ResendDocumentActionItem = ({
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
await resendDocument({ documentId: document.id, recipients });
toast({
title: 'Document re-sent',

View File

@ -2,14 +2,13 @@
import Link from 'next/link';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Download, Edit, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
@ -19,12 +18,10 @@ 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, team }: DataTableActionButtonProps) => {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@ -40,10 +37,6 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
const isPending = row.status === DocumentStatus.PENDING;
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 {
@ -52,7 +45,6 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
@ -76,11 +68,6 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
}
};
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
}
return match({
isOwner,
isRecipient,
@ -88,48 +75,27 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
isPending,
isComplete,
isSigned,
isCurrentTeamDocument,
})
.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({ 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({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link href={`/sign/${recipient?.token}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</>
))}
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</Link>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
Sign
</Button>
))
.with({ isComplete: true }, () => (

View File

@ -5,11 +5,9 @@ import { useState } from 'react';
import Link from 'next/link';
import {
CheckCircle,
Copy,
Download,
Edit,
EyeIcon,
Loader,
MoreHorizontal,
Pencil,
@ -20,9 +18,8 @@ import {
import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
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 { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } 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';
@ -43,12 +40,10 @@ 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, team }: DataTableActionDropdownProps) => {
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@ -60,17 +55,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
}
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
// const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT;
// const isPending = row.status === DocumentStatus.PENDING;
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 isDocumentDeletable = isOwner && !recipient;
const onDownloadClick = async () => {
try {
@ -79,7 +71,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
@ -114,35 +105,15 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
{recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
View
</>
)}
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="mr-2 h-4 w-4" />
Sign
</Link>
</DropdownMenuItem>
{recipient?.role === RecipientRole.SIGNER && (
<>
<Pencil className="mr-2 h-4 w-4" />
Sign
</>
)}
{recipient?.role === RecipientRole.APPROVER && (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</>
)}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}`}>
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
<Link href={`/documents/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
@ -170,7 +141,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuLabel>Share</DropdownMenuLabel>
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
<DocumentShareButton
documentId={row.id}
@ -200,7 +171,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
team={team}
/>
)}
</DropdownMenu>

View File

@ -1,63 +0,0 @@
'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}
/>
);
};

View File

@ -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, Team, User } from '@documenso/prisma/client';
import type { Document, Recipient, 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,18 +25,11 @@ 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,
showSenderColumn,
team,
}: DocumentsDataTableProps) => {
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
@ -68,11 +61,6 @@ export const DocumentsDataTable = ({
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',
@ -91,8 +79,8 @@ export const DocumentsDataTable = ({
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DataTableActionButton team={team} row={row.original} />
<DataTableActionDropdown team={team} row={row.original} />
<DataTableActionButton row={row.original} />
<DataTableActionDropdown row={row.original} />
</div>
),
},
@ -102,9 +90,6 @@ export const DocumentsDataTable = ({
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: Boolean(showSenderColumn),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>

View File

@ -1,155 +0,0 @@
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>
);
};

View File

@ -1,7 +1,5 @@
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 {
@ -18,21 +16,18 @@ 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
@ -42,12 +37,10 @@ export const DuplicateDocumentDialog = ({
}
: undefined;
const documentsPath = formatDocumentsPath(team?.url);
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
router.push(`${documentsPath}/${newId}`);
router.push(`/documents/${newId}`);
toast({
title: 'Document Duplicated',
@ -61,7 +54,7 @@ export const DuplicateDocumentDialog = ({
const onDuplicate = async () => {
try {
await duplicateDocument({ id, teamId: team?.id });
await duplicateDocument({ id });
} catch {
toast({
title: 'Something went wrong',

View File

@ -1,16 +1,114 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import type { DocumentsPageViewProps } from './documents-page-view';
import { DocumentsPageView } from './documents-page-view';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
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 type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
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';
export type DocumentsPageProps = {
searchParams?: DocumentsPageViewProps['searchParams'];
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
};
};
export const metadata: Metadata = {
title: 'Documents',
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const { user } = await getRequiredServerComponentSession();
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
return <DocumentsPageView searchParams={searchParams} />;
const stats = await getStats({
user,
});
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 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 defaultValue={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>
);
}

View File

@ -10,10 +10,8 @@ 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';
@ -22,13 +20,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type UploadDocumentProps = {
className?: string;
team?: {
id: number;
url: string;
};
};
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter();
const analytics = useAnalytics();
@ -44,15 +38,13 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
return team
? 'Document upload disabled due to unpaid invoices'
: 'You have reached your document limit.';
return 'You have reached your document limit.';
}
if (!session?.user.emailVerified) {
return 'Verify your email to upload documents.';
}
}, [remaining.documents, session?.user.emailVerified, team]);
}, [remaining.documents, session?.user.emailVerified]);
const onFileDrop = async (file: File) => {
try {
@ -68,7 +60,6 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const { id } = await createDocument({
title: file.name,
documentDataId,
teamId: team?.id,
});
toast({
@ -83,7 +74,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
router.push(`/documents/${id}`);
} catch (error) {
console.error(error);
@ -105,15 +96,6 @@ export const UploadDocument = ({ className, team }: 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
@ -121,17 +103,14 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
/>
<div className="absolute -bottom-6 right-0">
{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>
)}
{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 && (
@ -140,7 +119,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
</div>
)}
{team?.id === undefined && remaining.documents === 0 && (
{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">

View File

@ -7,7 +7,6 @@ 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';
@ -27,17 +26,13 @@ export default async function AuthenticatedDashboardLayout({
redirect('/signin');
}
const [{ user }, teams] = await Promise.all([
getRequiredServerComponentSession(),
getTeams({ userId: session.user.id }),
]);
const { user } = await getRequiredServerComponentSession();
return (
<NextAuthProvider session={session}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Header user={user} teams={teams} />
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@ -7,11 +7,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
export type BillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
};
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
export const BillingPortalButton = () => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
@ -52,11 +48,7 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
};
return (
<Button
{...buttonProps}
onClick={async () => handleFetchPortalUrl()}
loading={isFetchingPortalUrl}
>
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
Manage Subscription
</Button>
);

View File

@ -1,13 +1,11 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation';
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 { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
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';
@ -19,10 +17,6 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button';
export const metadata: Metadata = {
title: 'Billing',
};
export default async function BillingSettingsPage() {
let { user } = await getRequiredServerComponentSession();
@ -37,23 +31,23 @@ export default async function BillingSettingsPage() {
user = await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, communityPlanPrices] = await Promise.all([
const [subscriptions, prices, individualPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
getPricesByInterval({ type: 'individual' }),
getPricesByType('individual'),
]);
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
const individualPriceIds = individualPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
communityPlanPriceIds.includes(priceId),
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
individualPriceIds.includes(priceId),
);
const subscription =
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
communityPlanUserSubscriptions[0];
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
individualUserSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(

View File

@ -1,20 +1,17 @@
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 = {
title: 'Profile',
};
export default async function ProfileSettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<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" />
<ProfileForm user={user} className="max-w-xl" />
</div>

View File

@ -1,23 +0,0 @@
import type { Metadata } from 'next';
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
export const metadata: Metadata = {
title: 'Security activity',
};
export default function SettingsSecurityActivityPage() {
return (
<div>
<h3 className="text-2xl font-semibold">Security activity</h3>
<p className="text-muted-foreground mt-2 text-sm">
View all recent security activity related to your account.
</p>
<hr className="my-4" />
<UserSecurityActivityDataTable />
</div>
);
}

View File

@ -1,156 +0,0 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { LocaleDate } from '~/components/formatter/locale-date';
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const UserSecurityActivityDataTable = () => {
const parser = new UAParser();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isInitialLoading, isLoadingError } =
trpc.profile.findUserSecurityAuditLogs.useQuery(
{
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
keepPreviousData: true,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<DataTable
columns={[
{
header: 'Date',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'Device',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
let output = result.os.name;
if (!output) {
return 'N/A';
}
if (result.os.version) {
output += ` (${result.os.version})`;
}
return output;
},
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
},
{
header: 'Action',
accessorKey: 'type',
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
onClearFilters={() => router.push(pathname ?? '/')}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading && isInitialLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination table={table} />}
</DataTable>
);
};

View File

@ -1,100 +1,62 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
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';
export const metadata: Metadata = {
title: 'Security',
};
export default async function SecuritySettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<SettingsHeader
title="Security"
subtitle="Here you can manage your password and security settings."
/>
<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" />
{user.identityProvider === 'DOCUMENSO' ? (
<div>
<PasswordForm user={user} />
<PasswordForm user={user} className="max-w-xl" />
<hr className="border-border/50 mt-6" />
<hr className="mb-4 mt-8" />
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Two factor authentication</AlertTitle>
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
<AlertDescription className="mr-4">
Create one-time passwords that serve as a secondary authentication method for
confirming your identity when requested during the sign-in process.
</AlertDescription>
</div>
<p className="text-muted-foreground mt-2 text-sm">
Add and manage your two factor security settings to add an extra layer of security to
your account!
</p>
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Two-factor methods</h5>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</Alert>
</div>
{user.twoFactorEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the
event that you lose access to your authenticator app.
</AlertDescription>
</div>
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Recovery methods</h5>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</Alert>
</div>
)}
</div>
) : (
<Alert className="p-6" variant="neutral">
<AlertTitle>
<div>
<h4 className="text-lg font-medium">
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
</AlertTitle>
<AlertDescription>
</h4>
<p className="text-muted-foreground mt-2 text-sm">
To update your password, enable two-factor authentication, and manage other security
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
settings.
</AlertDescription>
</Alert>
)}
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>Recent activity</AlertTitle>
<AlertDescription className="mr-2">
View all recent security activity related to your account.
</AlertDescription>
</p>
</div>
<Button asChild>
<Link href="/settings/security/activity">View activity</Link>
</Button>
</Alert>
)}
</div>
);
}

View File

@ -1,45 +0,0 @@
'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>
);
};

View File

@ -1,39 +0,0 @@
'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>
);
}

View File

@ -1,105 +0,0 @@
'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>
);
};

View File

@ -1,83 +0,0 @@
'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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -2,16 +2,13 @@
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Loader, Plus } from 'lucide-react';
import { Loader, Plus } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Template } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -28,9 +25,6 @@ type TemplatesDataTableProps = {
perPage: number;
page: number;
totalPages: number;
documentRootPath: string;
templateRootPath: string;
teamId?: number;
};
export const TemplatesDataTable = ({
@ -38,15 +32,10 @@ export const TemplatesDataTable = ({
perPage,
page,
totalPages,
documentRootPath,
templateRootPath,
teamId,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const { remaining } = useLimits();
const router = useRouter();
const { toast } = useToast();
@ -76,7 +65,7 @@ export const TemplatesDataTable = ({
duration: 5000,
});
router.push(`${documentRootPath}/${id}`);
router.push(`/documents/${id}`);
} catch (err) {
toast({
title: 'Error',
@ -88,19 +77,6 @@ export const TemplatesDataTable = ({
return (
<div className="relative">
{remaining.documents === 0 && (
<Alert variant="warning" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Document Limit Exceeded!</AlertTitle>
<AlertDescription className="mt-2">
You have reached your document limit.{' '}
<Link className="underline underline-offset-4" href="/settings/billing">
Upgrade your account to continue!
</Link>
</AlertDescription>
</Alert>
)}
<DataTable
columns={[
{
@ -126,7 +102,7 @@ export const TemplatesDataTable = ({
return (
<div className="flex items-center gap-x-4">
<Button
disabled={isRowLoading || remaining.documents === 0}
disabled={isRowLoading}
loading={isRowLoading}
onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
@ -137,12 +113,7 @@ export const TemplatesDataTable = ({
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<DataTableActionDropdown
row={row.original}
teamId={teamId}
templateRootPath={templateRootPath}
/>
<DataTableActionDropdown row={row.original} />
</div>
);
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ export const DocumentPreviewButton = ({
{...props}
>
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
View Original Document
View Document
</Button>
<DocumentDialog documentData={documentData} open={showDialog} onOpenChange={setShowDialog} />

View File

@ -10,7 +10,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/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 { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@ -94,10 +94,7 @@ export default async function CompletedSigningPage({
))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have
{recipient.role === RecipientRole.SIGNER && ' signed '}
{recipient.role === RecipientRole.VIEWER && ' viewed '}
{recipient.role === RecipientRole.APPROVER && ' approved '}
You have signed
<span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2>
@ -131,7 +128,7 @@ export default async function CompletedSigningPage({
/>
) : (
<DocumentPreviewButton
className="text-[11px]"
className="flex-1"
title="Signatures will appear once the document has been completed"
documentData={documentData}
/>

View File

@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
@ -26,10 +26,9 @@ export type SigningFormProps = {
document: Document;
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
};
export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
@ -75,7 +74,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
timestamp: new Date().toISOString(),
});
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
router.push(`/sign/${recipient.token}/complete`);
};
return (
@ -97,114 +96,73 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
<fieldset
disabled={isSubmitting}
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && 'View Document'}
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
</h3>
{recipient.role === RecipientRole.VIEWER ? (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please mark as viewed to complete
</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4" />
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
/>
</div>
</div>
</>
) : (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
/>
</div>
</div>
</>
<div
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">Full Name</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad
className="h-44 w-full"
defaultValue={signature ?? undefined}
onChange={(value) => {
setSignature(value);
}}
/>
</CardContent>
</Card>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
/>
</div>
</div>
</div>
</fieldset>
</form>

View File

@ -1,8 +1,6 @@
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';
@ -14,16 +12,10 @@ 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} teams={teams} />}
{user && <AuthenticatedHeader user={user} />}
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
</div>

View File

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

View File

@ -1,4 +1,3 @@
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
@ -9,13 +8,13 @@ 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 { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -41,26 +40,24 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const requestHeaders = Object.fromEntries(headers().entries());
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token, requestMetadata }).catch(() => null),
viewedDocument({ token }).catch(() => null),
]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData, documentMeta } = document;
const { documentData } = document;
const { user } = await getServerComponentSession();
@ -68,9 +65,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED
) {
documentMeta?.redirectUrl
? redirect(documentMeta.redirectUrl)
: redirect(`/sign/${token}/complete`);
redirect(`/sign/${token}/complete`);
}
if (documentMeta?.password) {
@ -115,10 +110,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
<div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to{' '}
{recipient.role === RecipientRole.VIEWER && 'view'}
{recipient.role === RecipientRole.SIGNER && 'sign'}
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
{document.User.name} ({document.User.email}) has invited you to sign this document.
</p>
</div>
@ -138,12 +130,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
<SigningForm document={document} recipient={recipient} fields={fields} />
</div>
</div>

View File

@ -1,7 +1,6 @@
import { useState } from 'react';
import type { Document, Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -18,7 +17,6 @@ export type SignDialogProps = {
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
role: RecipientRole;
};
export const SignDialog = ({
@ -27,7 +25,6 @@ export const SignDialog = ({
fields,
fieldsValidated,
onSignatureComplete,
role,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title);
@ -48,18 +45,9 @@ export const SignDialog = ({
</DialogTrigger>
<DialogContent>
<div className="text-center">
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
{role === RecipientRole.SIGNER && 'Sign Document'}
{role === RecipientRole.APPROVER && 'Approve Document'}
</div>
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
{role === RecipientRole.VIEWER &&
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.SIGNER &&
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.APPROVER &&
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
You are about to finish signing "{truncatedTitle}". Are you sure?
</div>
</div>
@ -83,9 +71,7 @@ export const SignDialog = ({
loading={isSubmitting}
onClick={onSignatureComplete}
>
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
{role === RecipientRole.SIGNER && 'Sign'}
{role === RecipientRole.APPROVER && 'Approve'}
Sign
</Button>
</div>
</DialogFooter>

View File

@ -1,20 +0,0 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { 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} />;
}

View File

@ -1,25 +0,0 @@
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} />;
}

View File

@ -1,54 +0,0 @@
'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>
);
}

View File

@ -1,130 +0,0 @@
'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>
</>
);
};

View File

@ -1,65 +0,0 @@
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>
);
}

View File

@ -1,32 +0,0 @@
'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>
);
}

View File

@ -1,84 +0,0 @@
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>
);
}

View File

@ -1,54 +0,0 @@
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>
);
}

View File

@ -1,38 +0,0 @@
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>
);
}

View File

@ -1,186 +0,0 @@
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>
);
}

View File

@ -1,143 +0,0 @@
'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>
);
};

View File

@ -1,115 +0,0 @@
'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>
);
};

View File

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

View File

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

View File

@ -1,12 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button';
export const metadata: Metadata = {
title: 'Forgot password',
};
export default function ForgotPasswordPage() {
return (
<div>

View File

@ -1,12 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
export const metadata: Metadata = {
title: 'Forgot Password',
};
export default function ForgotPasswordPage() {
return (
<div>

View File

@ -1,12 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button';
export const metadata: Metadata = {
title: 'Reset Password',
};
export default function ResetPasswordPage() {
return (
<div>

View File

@ -1,30 +1,10 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
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';
export const metadata: Metadata = {
title: 'Sign In',
};
type SignInPageProps = {
searchParams: {
email?: string;
};
};
export default function SignInPage({ searchParams }: SignInPageProps) {
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
if (!email && rawEmail) {
redirect('/signin');
}
export default function SignInPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
@ -33,11 +13,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
Welcome back, we are lucky to have you.
</p>
<SignInForm
className="mt-4"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
<SignInForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">

View File

@ -1,34 +1,15 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
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';
export const metadata: Metadata = {
title: 'Sign Up',
};
type SignUpPageProps = {
searchParams: {
email?: string;
};
};
export default function SignUpPage({ searchParams }: SignUpPageProps) {
export default function SignUpPage() {
if (process.env.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>
@ -38,11 +19,7 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
signing is within your grasp.
</p>
<SignUpForm
className="mt-4"
initialEmail={email || undefined}
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
<SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}

View File

@ -1,121 +0,0 @@
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>
);
}

View File

@ -1,89 +0,0 @@
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>
);
}

View File

@ -1,80 +0,0 @@
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>
);
}

View File

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

View File

@ -1,14 +1,9 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { XCircle } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export const metadata: Metadata = {
title: 'Verify Email',
};
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex w-full items-start">

View File

@ -20,10 +20,7 @@ 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',
},
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:

View File

@ -4,7 +4,6 @@ import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
@ -48,17 +47,8 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<div
className="text-muted-foreground text-sm"
title="Click to copy signing link for sending to recipient"
>
<p>{recipient.email} </p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
);
}

View File

@ -1,5 +1,4 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
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';
import {
@ -60,12 +59,7 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
))}
</div>

View File

@ -197,22 +197,20 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
)}
{!currentPage && (
<>
<CommandGroup className="mx-2 p-0 pb-2" heading="Documents">
<CommandGroup heading="Documents">
<Commands push={push} pages={DOCUMENTS_PAGES} />
</CommandGroup>
<CommandGroup className="mx-2 p-0 pb-2" heading="Templates">
<CommandGroup heading="Templates">
<Commands push={push} pages={TEMPLATES_PAGES} />
</CommandGroup>
<CommandGroup className="mx-2 p-0 pb-2" heading="Settings">
<CommandGroup heading="Settings">
<Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup>
<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 heading="Preferences">
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
</CommandGroup>
{searchResults.length > 0 && (
<CommandGroup className="mx-2 p-0 pb-2" heading="Your documents">
<CommandGroup heading="Your documents">
<Commands push={push} pages={searchResults} />
</CommandGroup>
)}
@ -233,7 +231,6 @@ 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)}
@ -258,7 +255,7 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
<CommandItem
key={theme.theme}
onSelect={() => setTheme(theme.theme)}
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
className="mx-2 first:mt-2 last:mb-2"
>
<theme.icon className="mr-2" />
{theme.label}

View File

@ -4,11 +4,10 @@ import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { 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';
@ -29,13 +28,10 @@ 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);
@ -55,13 +51,11 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
{navigationLinks.map(({ href, label }) => (
<Link
key={href}
href={`${rootHref}${href}`}
href={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(
`${rootHref}${href}`,
),
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
},
)}
>

View File

@ -1,40 +1,23 @@
'use client';
import { type HTMLAttributes, useEffect, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { 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, teams, ...props }: HeaderProps) => {
const params = useParams();
const { getFlag } = useFeatureFlags();
const isTeamsEnabled = getFlag('app_teams');
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
export const Header = ({ className, user, ...props }: HeaderProps) => {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
@ -47,38 +30,10 @@ export const Header = ({ className, user, teams, ...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(
'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',
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[50] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
className,
)}
@ -86,8 +41,8 @@ export const Header = ({ className, user, teams, ...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={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"
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>
@ -95,24 +50,11 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
<DesktopNav />
<div className="flex gap-x-4 md:ml-8">
<MenuSwitcher user={user} teams={teams} />
</div>
<ProfileDropdown user={user} />
<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}
/>
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
<Menu className="h-6 w-6" />
</Button> */}
</div>
</div>
</header>

View File

@ -1,230 +0,0 @@
'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>
);
};

View File

@ -1,96 +0,0 @@
'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>
);
};

View File

@ -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 { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { recipientInitials } 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
? extractInitials(user.name)
? recipientInitials(user.name)
: user.email.slice(0, 1).toUpperCase();
return (
@ -68,7 +68,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-[60] w-56" align="end" forceMount>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel>Account</DropdownMenuLabel>
{isUserAdmin && (
@ -122,7 +122,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
Themes
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="z-[60]">
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenuRadioItem value="light">
<Sun className="mr-2 h-4 w-4" /> Light

View File

@ -21,9 +21,9 @@ export const PeriodSelector = () => {
const router = useRouter();
const period = useMemo(() => {
const p = searchParams?.get('period') ?? 'all';
const p = searchParams?.get('period') ?? '';
return isPeriodSelectorValue(p) ? p : 'all';
return isPeriodSelectorValue(p) ? p : '';
}, [searchParams]);
const onPeriodChange = (newPeriod: string) => {
@ -35,7 +35,7 @@ export const PeriodSelector = () => {
params.set('period', newPeriod);
if (newPeriod === '' || newPeriod === 'all') {
if (newPeriod === '') {
params.delete('period');
}
@ -49,7 +49,7 @@ export const PeriodSelector = () => {
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="">All Time</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="14d">Last 14 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>

View File

@ -1,4 +1,4 @@
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions

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