diff --git a/.env.example b/.env.example
index 06498f2bc..c482c128e 100644
--- a/.env.example
+++ b/.env.example
@@ -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=""
+NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
# REQUIRED: This should be a random string of at least 32 characters
-NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=""
+NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
# [[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_password"
+E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
@@ -74,6 +74,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
# OPTIONAL: The private key to use for DKIM signing.
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
+# OPTIONAL: Displays the maximum document upload limit to the user in MBs
+NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
# [[STRIPE]]
NEXT_PRIVATE_STRIPE_API_KEY=
diff --git a/apps/marketing/content/blog/commodifying-signing.mdx b/apps/marketing/content/blog/commodifying-signing.mdx
new file mode 100644
index 000000000..0a9cf4050
--- /dev/null
+++ b/apps/marketing/content/blog/commodifying-signing.mdx
@@ -0,0 +1,87 @@
+---
+title: Commodifying Signing
+description: We are creating signing as a public good and are commoditizing it to make it cheaper and better.
+authorName: 'Timur Ercan'
+authorImage: '/blog/blog-author-timur.jpeg'
+authorRole: 'Co-Founder'
+date: 2024-01-25
+Tags:
+ - Vision
+ - Mission
+ - Open Source
+---
+
+
+
+
+
+ Lighthouses are often used as an example of a public good; As they benefit all maritime users, but no one can be excluded from using them as a navigational aid. Use by one person neither prevents access by other people, nor does it reduce availability to others.
+
+
+
+# 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.
+
+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?
+
+- 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
+
+To understand why, we need to look at the landscape as it is today:
+
+- **Commodity**: Signing SaaS
+- **Private Goods**: Signing Code Base, Regulatory Know-How
+- **Public Goods**: Web Tech, Digital Signature Algorithms and Standards
+
+What the current players have done is to commodify the listed public goods into commercial products:
+
+> […]the action and process of transforming goods, services, ideas, nature, personal information, people, or animals into commodities.
+> (Let's ignore the end of that list for now and what it says about humanity, yikes)
+
+While this paradigm brought digital signing to many businesses worldwide, we aim for a different future. To solve signing once and for all, we need to achieve two core points:
+
+- Making it cheaper so it's profitable for everyone to use
+- Making it more accessible so everyone can use it (e.g. regulated industries) and flexible enough (extendable, open).
+
+To achieve this, we must transform the landscape to look like this:
+
+- **Commodities**: Enterprise Components, Support, Hosting, Self-Host Licenses
+- **Public Goods**: (no longer private): OS (Open Source) Signing Code Base, OS Regulatory Know-How
+- **Public Goods**: OS Web Tech, Digital Signature Algorithms and Standards
+
+## Raising the Bar
+
+Before creating a commodity, we are raising the bar of what the underlying public good is. Having an open source singing framework you can extend, self-host, and understand makes the resulting solution much more accessible and extendable for everyone. Now for the final feat of making signing cheaper:
+
+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.
+
+## Changing the Game
+
+In this new world, a company needing signing (literally every company) can decide if the ROI (Return on Investment) of building signing themselves is greater than simply paying for the value-added activities they will need anyway. Pricing our offering not on volume but fixed is a nice additional wedge into the market we intend to use here.
+
+The market dynamic now changes to who can offer the greatest value added to the public goods, driving the price down as this can be done much more efficiently than locking customers into closed source SaaS. Documenso, being a lean company, which we intend to stay with for a long time, will help kickstart this effect. Open Source capital efficiency is real. Our planned enterprise components, hosting support, and partner ecosystem will all leverage this effect.
+
+We will grow our community around the public good, the open-source repo, and create an ecosystem around the commodities built on top of it (components, hosting, compliance, support). We will solve signing once and for all, and the world will be better for it. Onwards.
+
+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 or comments.
+
+Best from Hamburg\
+Timur
diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx
index 27b1ae208..1267931d6 100644
--- a/apps/marketing/content/blog/linear-gh.mdx
+++ b/apps/marketing/content/blog/linear-gh.mdx
@@ -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
diff --git a/apps/marketing/content/blog/manifest.mdx b/apps/marketing/content/blog/manifest.mdx
index 4abd7c068..7f2b7e7cd 100644
--- a/apps/marketing/content/blog/manifest.mdx
+++ b/apps/marketing/content/blog/manifest.mdx
@@ -7,6 +7,8 @@ authorRole: 'Co-Founder'
date: 2023-07-13
tags:
- Manifesto
+ - Open Source
+ - Vision
---
diff --git a/apps/marketing/public/blog/lighthouse.jpeg b/apps/marketing/public/blog/lighthouse.jpeg
new file mode 100644
index 000000000..d71e1eb51
Binary files /dev/null and b/apps/marketing/public/blog/lighthouse.jpeg differ
diff --git a/apps/marketing/src/app/(marketing)/[content]/page.tsx b/apps/marketing/src/app/(marketing)/[content]/page.tsx
index 5c846e9f2..62c83f400 100644
--- a/apps/marketing/src/app/(marketing)/[content]/page.tsx
+++ b/apps/marketing/src/app/(marketing)/[content]/page.tsx
@@ -15,7 +15,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
notFound();
}
- return { title: `Documenso - ${document.title}` };
+ return { title: document.title };
};
const mdxComponents: MDXComponents = {
diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx
index f1952cc72..866539a92 100644
--- a/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx
+++ b/apps/marketing/src/app/(marketing)/blog/[post]/page.tsx
@@ -18,7 +18,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
}
return {
- title: `Documenso - ${blogPost.title}`,
+ title: {
+ absolute: `${blogPost.title} - Documenso Blog`,
+ },
description: blogPost.description,
};
};
diff --git a/apps/marketing/src/app/(marketing)/blog/page.tsx b/apps/marketing/src/app/(marketing)/blog/page.tsx
index 747a56ddf..2eac963d1 100644
--- a/apps/marketing/src/app/(marketing)/blog/page.tsx
+++ b/apps/marketing/src/app/(marketing)/blog/page.tsx
@@ -1,5 +1,10 @@
+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);
diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx
index 248414b33..dd1a46418 100644
--- a/apps/marketing/src/app/(marketing)/layout.tsx
+++ b/apps/marketing/src/app/(marketing)/layout.tsx
@@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
- {children}
+ {children}
diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx
index e237919bc..a1fea41e4 100644
--- a/apps/marketing/src/app/(marketing)/open/page.tsx
+++ b/apps/marketing/src/app/(marketing)/open/page.tsx
@@ -1,3 +1,5 @@
+import type { Metadata } from 'next';
+
import { z } from 'zod';
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
@@ -14,6 +16,10 @@ 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';
diff --git a/apps/marketing/src/app/(marketing)/oss-friends/page.tsx b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx
index a91446408..65a4a55f8 100644
--- a/apps/marketing/src/app/(marketing)/oss-friends/page.tsx
+++ b/apps/marketing/src/app/(marketing)/oss-friends/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from 'next';
import Image from 'next/image';
import { z } from 'zod';
@@ -5,7 +6,12 @@ import { z } from 'zod';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { OSSFriendsContainer } from './container';
-import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';
+import type { TOSSFriendsSchema } from './schema';
+import { ZOSSFriendsSchema } from './schema';
+
+export const metadata: Metadata = {
+ title: 'OSS Friends',
+};
export default async function OSSFriendsPage() {
const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', {
diff --git a/apps/marketing/src/app/(marketing)/page.tsx b/apps/marketing/src/app/(marketing)/page.tsx
index 377384701..10918299a 100644
--- a/apps/marketing/src/app/(marketing)/page.tsx
+++ b/apps/marketing/src/app/(marketing)/page.tsx
@@ -1,4 +1,5 @@
/* 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';
@@ -10,6 +11,11 @@ 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'],
diff --git a/apps/marketing/src/app/(marketing)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx
index 92043b3b3..e4c7b776a 100644
--- a/apps/marketing/src/app/(marketing)/pricing/page.tsx
+++ b/apps/marketing/src/app/(marketing)/pricing/page.tsx
@@ -1,5 +1,4 @@
-'use client';
-
+import type { Metadata } from 'next';
import Link from 'next/link';
import {
@@ -12,6 +11,10 @@ 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;
diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
index 389528bf8..a1b56257a 100644
--- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
+++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx
@@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
+ role: 'SIGNER',
};
const onFileDrop = async (file: File) => {
diff --git a/apps/marketing/src/app/(marketing)/singleplayer/page.tsx b/apps/marketing/src/app/(marketing)/singleplayer/page.tsx
index a98906476..aafad32a8 100644
--- a/apps/marketing/src/app/(marketing)/singleplayer/page.tsx
+++ b/apps/marketing/src/app/(marketing)/singleplayer/page.tsx
@@ -1,5 +1,11 @@
+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
diff --git a/apps/marketing/src/app/layout.tsx b/apps/marketing/src/app/layout.tsx
index 05206a76f..1745149c6 100644
--- a/apps/marketing/src/app/layout.tsx
+++ b/apps/marketing/src/app/layout.tsx
@@ -18,7 +18,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
- title: 'Documenso - The Open Source DocuSign Alternative',
+ title: {
+ template: '%s - Documenso',
+ default: 'Documenso',
+ },
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx
index 80c13b275..d4305a04c 100644
--- a/apps/marketing/src/components/(marketing)/widget.tsx
+++ b/apps/marketing/src/components/(marketing)/widget.tsx
@@ -399,6 +399,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
{
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
- ? recipientInitials(row.original.User.name)
+ ? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
diff --git a/packages/ui/primitives/multiselect-combobox.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx
similarity index 95%
rename from packages/ui/primitives/multiselect-combobox.tsx
rename to apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx
index bac87ce0b..9a25af897 100644
--- a/packages/ui/primitives/multiselect-combobox.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx
@@ -19,7 +19,7 @@ type ComboboxProps = {
onChange: (_values: string[]) => void;
};
-const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
+const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
const [open, setOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState([]);
const dbRoles = Object.values(Role);
@@ -79,4 +79,4 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
);
};
-export { MultiSelectCombobox };
+export { MultiSelectRoleCombobox };
diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
index 9ae270d28..3bd909623 100644
--- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
@@ -18,9 +18,10 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
-import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
import { useToast } from '@documenso/ui/primitives/use-toast';
+import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
+
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer;
@@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
Roles
- onChange(values)}
/>
diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx
index 069378274..577e0739a 100644
--- a/apps/web/src/app/(dashboard)/admin/users/page.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx
@@ -1,4 +1,5 @@
-import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
+import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
+import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { UsersDataTable } from './data-table-users';
import { search } from './fetch-users.actions';
@@ -18,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
- getPricesByType('individual'),
+ getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx
new file mode 100644
index 000000000..3a46ed5e7
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx
@@ -0,0 +1,131 @@
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+
+import { ChevronLeft, Users2 } from 'lucide-react';
+
+import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
+import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
+import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
+import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Team } from '@documenso/prisma/client';
+import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
+import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
+
+import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
+import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
+import { DocumentStatus } from '~/components/formatter/document-status';
+
+export type DocumentPageViewProps = {
+ params: {
+ id: string;
+ };
+ team?: Team;
+};
+
+export default async function DocumentPageView({ 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 (
+
+
+
+ Documents
+
+
+
+ {document.title}
+
+
+
+
+
+ {recipients.length > 0 && (
+
+
+
+
+ {recipients.length} Recipient(s)
+
+
+ )}
+
+
+ {document.status !== InternalDocumentStatus.COMPLETED && (
+
+ )}
+
+ {document.status === InternalDocumentStatus.COMPLETED && (
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
index 2159b87f2..e6cbd6fd4 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
@@ -32,6 +32,7 @@ export type EditDocumentFormProps = {
documentMeta: DocumentMeta | null;
fields: Field[];
documentData: DocumentData;
+ documentRootPath: string;
};
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
@@ -45,6 +46,7 @@ export const EditDocumentForm = ({
documentMeta,
user: _user,
documentData,
+ documentRootPath,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
@@ -168,7 +170,7 @@ export const EditDocumentForm = ({
duration: 5000,
});
- router.push('/documents');
+ router.push(documentRootPath);
} catch (err) {
console.error(err);
@@ -218,9 +220,9 @@ export const EditDocumentForm = ({
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx
index 44f3991d8..e7a34889e 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx
@@ -1,20 +1,4 @@
-import Link from 'next/link';
-import { redirect } from 'next/navigation';
-
-import { ChevronLeft, Users2 } from 'lucide-react';
-
-import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
-import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
-import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
-import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
-import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
-import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
-import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
-import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
-
-import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
-import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
-import { DocumentStatus } from '~/components/formatter/document-status';
+import DocumentPageView from './document-page-view';
export type DocumentPageProps = {
params: {
@@ -22,103 +6,6 @@ export type DocumentPageProps = {
};
};
-export default async function DocumentPage({ params }: DocumentPageProps) {
- const { id } = params;
-
- const documentId = Number(id);
-
- if (!documentId || Number.isNaN(documentId)) {
- redirect('/documents');
- }
-
- const { user } = await getRequiredServerComponentSession();
-
- const document = await getDocumentById({
- id: documentId,
- userId: user.id,
- }).catch(() => null);
-
- if (!document || !document.documentData) {
- redirect('/documents');
- }
-
- const { documentData, documentMeta } = document;
-
- if (documentMeta?.password) {
- const key = DOCUMENSO_ENCRYPTION_KEY;
-
- if (!key) {
- throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
- }
-
- const securePassword = Buffer.from(
- symmetricDecrypt({
- key,
- data: documentMeta.password,
- }),
- ).toString('utf-8');
-
- documentMeta.password = securePassword;
- }
-
- const [recipients, fields] = await Promise.all([
- getRecipientsForDocument({
- documentId,
- userId: user.id,
- }),
- getFieldsForDocument({
- documentId,
- userId: user.id,
- }),
- ]);
-
- return (
-
-
-
- Documents
-
-
-
- {document.title}
-
-
-
-
-
- {recipients.length > 0 && (
-
-
-
-
- {recipients.length} Recipient(s)
-
-
- )}
-
-
- {document.status !== InternalDocumentStatus.COMPLETED && (
-
- )}
-
- {document.status === InternalDocumentStatus.COMPLETED && (
-
-
-
- )}
-
- );
+export default function DocumentPage({ params }: DocumentPageProps) {
+ return ;
}
diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
index 7fabeef95..e8e3d6130 100644
--- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
@@ -10,6 +10,7 @@ import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
+import type { Team } from '@documenso/prisma/client';
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -39,8 +40,11 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
- document: Document;
+ document: Document & {
+ team: Pick | null;
+ };
recipients: Recipient[];
+ team?: Pick;
};
export const ZResendDocumentFormSchema = z.object({
@@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer {
const { data: session } = useSession();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
+ const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =
- !isOwner ||
+ (!isOwner && !isCurrentTeamDocument) ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
@@ -82,7 +88,7 @@ export const ResendDocumentActionItem = ({
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
- await resendDocument({ documentId: document.id, recipients });
+ await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
toast({
title: 'Document re-sent',
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
index 9910ef111..78ffd0b3b 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
@@ -2,13 +2,14 @@
import Link from 'next/link';
-import { Download, Edit, Pencil } from 'lucide-react';
+import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
-import type { Document, Recipient, User } from '@documenso/prisma/client';
-import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
+import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,10 +19,12 @@ export type DataTableActionButtonProps = {
row: Document & {
User: Pick;
Recipient: Recipient[];
+ team: Pick | null;
};
+ team?: Pick;
};
-export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
+export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@@ -37,6 +40,10 @@ export const DataTableActionButton = ({ row }: 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 {
@@ -45,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
+ teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
@@ -68,6 +76,11 @@ export const DataTableActionButton = ({ row }: 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,
@@ -75,27 +88,48 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
isPending,
isComplete,
isSigned,
+ isCurrentTeamDocument,
})
- .with({ isOwner: true, isDraft: true }, () => (
-
-
-
- Edit
-
-
- ))
+ .with(
+ isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
+ () => (
+
+
+
+ Edit
+
+
+ ),
+ )
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
-
- Sign
+ {match(role)
+ .with(RecipientRole.SIGNER, () => (
+ <>
+
+ Sign
+ >
+ ))
+ .with(RecipientRole.APPROVER, () => (
+ <>
+
+ Approve
+ >
+ ))
+ .otherwise(() => (
+ <>
+
+ View
+ >
+ ))}
))
.with({ isPending: true, isSigned: true }, () => (
-
- Sign
+
+ View
))
.with({ isComplete: true }, () => (
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx
index f14321b35..b7d2cf452 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx
@@ -5,9 +5,11 @@ import { useState } from 'react';
import Link from 'next/link';
import {
+ CheckCircle,
Copy,
Download,
Edit,
+ EyeIcon,
Loader,
MoreHorizontal,
Pencil,
@@ -18,8 +20,9 @@ import {
import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
-import type { Document, Recipient, User } from '@documenso/prisma/client';
-import { DocumentStatus } from '@documenso/prisma/client';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
+import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -40,10 +43,12 @@ export type DataTableActionDropdownProps = {
row: Document & {
User: Pick;
Recipient: Recipient[];
+ team: Pick | null;
};
+ team?: Pick;
};
-export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
+export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@@ -63,6 +68,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
+ const isCurrentTeamDocument = team && row.team?.url === team.url;
+
+ const documentsPath = formatDocumentsPath(team?.url);
const onDownloadClick = async () => {
try {
@@ -71,6 +79,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
+ teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
@@ -105,15 +114,35 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Action
-
-
-
- Sign
-
-
+ {recipient?.role !== RecipientRole.CC && (
+
+
+ {recipient?.role === RecipientRole.VIEWER && (
+ <>
+
+ View
+ >
+ )}
-
-
+ {recipient?.role === RecipientRole.SIGNER && (
+ <>
+
+ Sign
+ >
+ )}
+
+ {recipient?.role === RecipientRole.APPROVER && (
+ <>
+
+ Approve
+ >
+ )}
+
+
+ )}
+
+
+
Edit
@@ -141,7 +170,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Share
-
+
)}
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-sender-filter.tsx b/apps/web/src/app/(dashboard)/documents/data-table-sender-filter.tsx
new file mode 100644
index 000000000..6c66153a7
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/data-table-sender-filter.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
+import { parseToIntegerArray } from '@documenso/lib/utils/params';
+import { trpc } from '@documenso/trpc/react';
+import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
+
+type DataTableSenderFilterProps = {
+ teamId: number;
+};
+
+export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ const isMounted = useIsMounted();
+
+ const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
+
+ const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({
+ teamId,
+ });
+
+ const comboBoxOptions = (data ?? []).map((member) => ({
+ label: member.user.name ?? member.user.email,
+ value: member.user.id,
+ }));
+
+ const onChange = (newSenderIds: number[]) => {
+ if (!pathname) {
+ return;
+ }
+
+ const params = new URLSearchParams(searchParams?.toString());
+
+ params.set('senderIds', newSenderIds.join(','));
+
+ if (newSenderIds.length === 0) {
+ params.delete('senderIds');
+ }
+
+ router.push(`${pathname}?${params.toString()}`, { scroll: false });
+ };
+
+ return (
+
+ Sender: All
+
+ }
+ enableClearAllButton={true}
+ inputPlaceholder="Search"
+ loading={!isMounted || isInitialLoading}
+ options={comboBoxOptions}
+ selectedValues={senderIds}
+ onChange={onChange}
+ />
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx
index c8adb1422..13b85d526 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx
@@ -7,7 +7,7 @@ import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
-import type { Document, Recipient, User } from '@documenso/prisma/client';
+import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -25,11 +25,18 @@ export type DocumentsDataTableProps = {
Document & {
Recipient: Recipient[];
User: Pick;
+ team: Pick | null;
}
>;
+ showSenderColumn?: boolean;
+ team?: Pick;
};
-export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
+export const DocumentsDataTable = ({
+ results,
+ showSenderColumn,
+ team,
+}: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
@@ -61,6 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
header: 'Title',
cell: ({ row }) => ,
},
+ {
+ id: 'sender',
+ header: 'Sender',
+ cell: ({ row }) => row.original.User.name ?? row.original.User.email,
+ },
{
header: 'Recipient',
accessorKey: 'recipient',
@@ -79,8 +91,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
-
-
+
+
),
},
@@ -90,6 +102,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
+ columnVisibility={{
+ sender: Boolean(showSenderColumn),
+ }}
>
{(table) => }
diff --git a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
new file mode 100644
index 000000000..ead3e8f4f
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
@@ -0,0 +1,158 @@
+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 default async function DocumentsPageView({
+ 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 (
+
+
+
+
+
+ {team && (
+
+
+ {team.name.slice(0, 1)}
+
+
+ )}
+
+
Documents
+
+
+
+
+
+ {[
+ ExtendedDocumentStatus.INBOX,
+ ExtendedDocumentStatus.PENDING,
+ ExtendedDocumentStatus.COMPLETED,
+ ExtendedDocumentStatus.DRAFT,
+ ExtendedDocumentStatus.ALL,
+ ].map((value) => (
+
+
+
+
+ {value !== ExtendedDocumentStatus.ALL && (
+
+ {Math.min(stats[value], 99)}
+ {stats[value] > 99 && '+'}
+
+ )}
+
+
+ ))}
+
+
+
+ {team &&
}
+
+
+
+
+
+
+ {results.count > 0 && (
+
+ )}
+ {results.count === 0 && }
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
index 56c112d75..14370cff8 100644
--- a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
@@ -1,5 +1,7 @@
import { useRouter } from 'next/navigation';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Team } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -16,18 +18,21 @@ type DuplicateDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
+ team?: Pick;
};
export const DuplicateDocumentDialog = ({
id,
open,
onOpenChange,
+ team,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
+ teamId: team?.id,
});
const documentData = document?.documentData
@@ -37,10 +42,12 @@ export const DuplicateDocumentDialog = ({
}
: undefined;
+ const documentsPath = formatDocumentsPath(team?.url);
+
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
- router.push(`/documents/${newId}`);
+ router.push(`${documentsPath}/${newId}`);
toast({
title: 'Document Duplicated',
@@ -54,7 +61,7 @@ export const DuplicateDocumentDialog = ({
const onDuplicate = async () => {
try {
- await duplicateDocument({ id });
+ await duplicateDocument({ id, teamId: team?.id });
} catch {
toast({
title: 'Something went wrong',
diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx
index 8bb321377..b67ed6f02 100644
--- a/apps/web/src/app/(dashboard)/documents/page.tsx
+++ b/apps/web/src/app/(dashboard)/documents/page.tsx
@@ -1,114 +1,16 @@
-import Link from 'next/link';
+import type { Metadata } from 'next';
-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';
+import type { DocumentsPageViewProps } from './documents-page-view';
+import DocumentsPageView from './documents-page-view';
export type DocumentsPageProps = {
- searchParams?: {
- status?: ExtendedDocumentStatus;
- period?: PeriodSelectorValue;
- page?: string;
- perPage?: string;
- };
+ searchParams?: DocumentsPageViewProps['searchParams'];
};
-export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
- const { user } = await getRequiredServerComponentSession();
+export const metadata: Metadata = {
+ title: 'Documents',
+};
- 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 (
-
-
-
-
-
Documents
-
-
-
-
- {[
- ExtendedDocumentStatus.INBOX,
- ExtendedDocumentStatus.PENDING,
- ExtendedDocumentStatus.COMPLETED,
- ExtendedDocumentStatus.DRAFT,
- ExtendedDocumentStatus.ALL,
- ].map((value) => (
-
-
-
-
- {value !== ExtendedDocumentStatus.ALL && (
-
- {Math.min(stats[value], 99)}
- {stats[value] > 99 && '+'}
-
- )}
-
-
- ))}
-
-
-
-
-
-
-
-
- {results.count > 0 && }
- {results.count === 0 && }
-
-
- );
+export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
+ return ;
}
diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
index 65b95f9ec..ed91620dc 100644
--- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
@@ -10,8 +10,10 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
+import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,9 +22,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type UploadDocumentProps = {
className?: string;
+ team?: {
+ id: number;
+ url: string;
+ };
};
-export const UploadDocument = ({ className }: UploadDocumentProps) => {
+export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const router = useRouter();
const analytics = useAnalytics();
@@ -38,13 +44,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
- return 'You have reached your document limit.';
+ return team
+ ? 'Document upload disabled due to unpaid invoices'
+ : 'You have reached your document limit.';
}
if (!session?.user.emailVerified) {
return 'Verify your email to upload documents.';
}
- }, [remaining.documents, session?.user.emailVerified]);
+ }, [remaining.documents, session?.user.emailVerified, team]);
const onFileDrop = async (file: File) => {
try {
@@ -60,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { id } = await createDocument({
title: file.name,
documentDataId,
+ teamId: team?.id,
});
toast({
@@ -74,7 +83,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
- router.push(`/documents/${id}`);
+ router.push(`${formatDocumentsPath(team?.url)}/${id}`);
} catch (error) {
console.error(error);
@@ -96,6 +105,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
}
};
+ const onFileDropRejected = () => {
+ toast({
+ title: 'Your document failed to upload.',
+ description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
+ duration: 5000,
+ variant: 'destructive',
+ });
+ };
+
return (
{
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
+ onDropRejected={onFileDropRejected}
/>
- {remaining.documents > 0 && Number.isFinite(remaining.documents) && (
-
- {remaining.documents} of {quota.documents} documents remaining this month.
-
- )}
+ {team?.id === undefined &&
+ remaining.documents > 0 &&
+ Number.isFinite(remaining.documents) && (
+
+ {remaining.documents} of {quota.documents} documents remaining this month.
+
+ )}
{isLoading && (
@@ -119,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
)}
- {remaining.documents === 0 && (
+ {team?.id === undefined && remaining.documents === 0 && (
diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx
index 433aeb18c..99db66c55 100644
--- a/apps/web/src/app/(dashboard)/layout.tsx
+++ b/apps/web/src/app/(dashboard)/layout.tsx
@@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
@@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
redirect('/signin');
}
- const { user } = await getRequiredServerComponentSession();
+ const [{ user }, teams] = await Promise.all([
+ getRequiredServerComponentSession(),
+ getTeams({ userId: session.user.id }),
+ ]);
return (
{!user.emailVerified && }
-
+
+
{children}
diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx
index 8fd78cae3..9ed6a2515 100644
--- a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx
+++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx
@@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
-export const BillingPortalButton = () => {
+export type BillingPortalButtonProps = {
+ buttonProps?: React.ComponentProps;
+};
+
+export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
@@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
};
return (
- handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
+ handleFetchPortalUrl()}
+ loading={isFetchingPortalUrl}
+ >
Manage Subscription
);
diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx
index 74e4bd685..cee2aa2f1 100644
--- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx
@@ -1,11 +1,13 @@
+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 { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
+import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
+import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { type Stripe } from '@documenso/lib/server-only/stripe';
@@ -17,6 +19,10 @@ 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();
@@ -31,23 +37,23 @@ export default async function BillingSettingsPage() {
user = await getStripeCustomerByUser(user).then((result) => result.user);
}
- const [subscriptions, prices, individualPrices] = await Promise.all([
+ const [subscriptions, prices, communityPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
- getPricesByInterval({ type: 'individual' }),
- getPricesByType('individual'),
+ getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
+ getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
]);
- const individualPriceIds = individualPrices.map(({ id }) => id);
+ const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
- const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
- individualPriceIds.includes(priceId),
+ const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
+ communityPlanPriceIds.includes(priceId),
);
const subscription =
- individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
- individualUserSubscriptions[0];
+ communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
+ communityPlanUserSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx
index cb64fb9cd..2890eb5d5 100644
--- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx
@@ -1,17 +1,20 @@
+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 (
-
Profile
-
-
Here you can edit your personal details.
-
-
+
diff --git a/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx
new file mode 100644
index 000000000..6e183b0c7
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx
@@ -0,0 +1,23 @@
+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 (
+
+
Security activity
+
+
+ View all recent security activity related to your account.
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/settings/security/activity/user-security-activity-data-table.tsx b/apps/web/src/app/(dashboard)/settings/security/activity/user-security-activity-data-table.tsx
new file mode 100644
index 000000000..4937749fc
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/security/activity/user-security-activity-data-table.tsx
@@ -0,0 +1,156 @@
+'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 (
+ ,
+ },
+ {
+ 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: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx
index ae97e7fb5..f46784aed 100644
--- a/apps/web/src/app/(dashboard)/settings/security/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx
@@ -1,62 +1,100 @@
+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 (
-
Security
-
-
- Here you can manage your password and security settings.
-
-
-
+
{user.identityProvider === 'DOCUMENSO' ? (
-
+
-
+
-
Two Factor Authentication
+
+
+
Two factor authentication
-
- Add and manage your two factor security settings to add an extra layer of security to
- your account!
-
-
-
-
Two-factor methods
+
+ Create one-time passwords that serve as a secondary authentication method for
+ confirming your identity when requested during the sign-in process.
+
+
-
+
{user.twoFactorEnabled && (
-
-
Recovery methods
+
+
+
Recovery codes
+
+
+ Two factor authentication recovery codes are used to access your account in the
+ event that you lose access to your authenticator app.
+
+
-
+
)}
) : (
-
-
+
+
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
-
-
+
+
+
To update your password, enable two-factor authentication, and manage other security
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
settings.
-
-
+
+
)}
+
+
+
+
Recent activity
+
+
+ View all recent security activity related to your account.
+
+
+
+
+ View activity
+
+
);
}
diff --git a/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx
new file mode 100644
index 000000000..8aa81653d
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type AcceptTeamInvitationButtonProps = {
+ teamId: number;
+};
+
+export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
+ const { toast } = useToast();
+
+ const {
+ mutateAsync: acceptTeamInvitation,
+ isLoading,
+ isSuccess,
+ } = trpc.team.acceptTeamInvitation.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Accepted team invitation',
+ duration: 5000,
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ variant: 'destructive',
+ duration: 10000,
+ description: 'Unable to join this team at this time.',
+ });
+ },
+ });
+
+ return (
+ acceptTeamInvitation({ teamId })}
+ loading={isLoading}
+ disabled={isLoading || isSuccess}
+ >
+ Accept
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/teams/page.tsx b/apps/web/src/app/(dashboard)/settings/teams/page.tsx
new file mode 100644
index 000000000..1a3d90b66
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/teams/page.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { AnimatePresence } from 'framer-motion';
+
+import { trpc } from '@documenso/trpc/react';
+import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+import { CreateTeamDialog } from '~/components/(teams)/dialogs/create-team-dialog';
+import { UserSettingsTeamsPageDataTable } from '~/components/(teams)/tables/user-settings-teams-page-data-table';
+
+import { TeamEmailUsage } from './team-email-usage';
+import { TeamInvitations } from './team-invitations';
+
+export default function TeamsSettingsPage() {
+ const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {teamEmail && (
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
new file mode 100644
index 000000000..56a7b110a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import { useState } from 'react';
+
+import type { TeamEmail } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type TeamEmailUsageProps = {
+ teamEmail: TeamEmail & { team: { name: string; url: string } };
+};
+
+export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
+ trpc.team.deleteTeamEmail.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'You have successfully revoked access.',
+ duration: 5000,
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
+ });
+ },
+ });
+
+ return (
+
+
+
Team Email
+
+
+ Your email is currently being used by team{' '}
+ {teamEmail.team.name} ({teamEmail.team.url}
+ ).
+
+
+ They have permission on your behalf to:
+
+
+ Display your name and email in documents
+ View all documents sent to your account
+
+
+
+
+ !isDeletingTeamEmail && setOpen(value)}>
+
+ Revoke access
+
+
+
+
+ Are you sure?
+
+
+ You are about to revoke access for team{' '}
+ {teamEmail.team.name} ({teamEmail.team.url}) to
+ use your email.
+
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ deleteTeamEmail({ teamId: teamEmail.teamId })}
+ >
+ Revoke
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx
new file mode 100644
index 000000000..aa1be3f3f
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { AnimatePresence } from 'framer-motion';
+import { BellIcon } from 'lucide-react';
+
+import { formatTeamUrl } from '@documenso/lib/utils/teams';
+import { trpc } from '@documenso/trpc/react';
+import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
+import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+
+import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
+
+export const TeamInvitations = () => {
+ const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery();
+
+ return (
+
+ {data && data.length > 0 && !isInitialLoading && (
+
+
+
+
+
+
+ You have {data.length} pending team invitation
+ {data.length > 1 ? 's' : ''}.
+
+
+
+
+
+ View invites
+
+
+
+
+
+ Pending invitations
+
+
+ You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
+
+
+
+
+ {data.map((invitation) => (
+
+
+ {invitation.team.name}
+
+ }
+ secondaryText={formatTeamUrl(invitation.team.url)}
+ rightSideComponent={
+
+ }
+ />
+
+ ))}
+
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
index 63d6888b1..0e8f822c2 100644
--- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
+++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
@@ -2,13 +2,16 @@
import { useState, useTransition } from 'react';
+import Link from 'next/link';
import { useRouter } from 'next/navigation';
-import { Loader, Plus } from 'lucide-react';
+import { AlertTriangle, Loader, Plus } from 'lucide-react';
+import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { 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';
@@ -36,6 +39,8 @@ export const TemplatesDataTable = ({
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
+ const { remaining } = useLimits();
+
const router = useRouter();
const { toast } = useToast();
@@ -77,6 +82,19 @@ export const TemplatesDataTable = ({
return (
+ {remaining.documents === 0 && (
+
+
+ Document Limit Exceeded!
+
+ You have reached your document limit.{' '}
+
+ Upgrade your account to continue!
+
+
+
+ )}
+
{
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx
index f4167e42a..d3dacd501 100644
--- a/apps/web/src/app/(dashboard)/templates/page.tsx
+++ b/apps/web/src/app/(dashboard)/templates/page.tsx
@@ -1,5 +1,7 @@
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';
@@ -14,6 +16,10 @@ type TemplatesPageProps = {
};
};
+export const metadata: Metadata = {
+ title: 'Templates',
+};
+
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1;
diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx
index 1ac50f1c0..c0881bd44 100644
--- a/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx
@@ -30,7 +30,7 @@ export const DocumentPreviewButton = ({
{...props}
>
- View Document
+ View Original Document
diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
index ab73755ab..a64831804 100644
--- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx
@@ -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 } from '@documenso/prisma/client';
+import { DocumentStatus, FieldType, RecipientRole } 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,7 +94,10 @@ export default async function CompletedSigningPage({
))}
- You have signed
+ You have
+ {recipient.role === RecipientRole.SIGNER && ' signed '}
+ {recipient.role === RecipientRole.VIEWER && ' viewed '}
+ {recipient.role === RecipientRole.APPROVER && ' approved '}
"{truncatedTitle}"
@@ -128,7 +131,7 @@ export default async function CompletedSigningPage({
/>
) : (
diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx
index f5c94e6ec..7105baafd 100644
--- a/apps/web/src/app/(signing)/sign/[token]/form.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx
@@ -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, Field, Recipient } from '@documenso/prisma/client';
+import { type Document, type Field, type Recipient, RecipientRole } 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';
@@ -96,73 +96,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
-
-
Sign Document
+
+
+ {recipient.role === RecipientRole.VIEWER && 'View Document'}
+ {recipient.role === RecipientRole.SIGNER && 'Sign Document'}
+ {recipient.role === RecipientRole.APPROVER && 'Approve Document'}
+
-
- Please review the document before signing.
-
+ {recipient.role === RecipientRole.VIEWER ? (
+ <>
+
+ Please mark as viewed to complete
+
-
+
-
-
-
-
Full Name
+
+
+
+ router.back()}
+ >
+ Cancel
+
- setFullName(e.target.value.trimStart())}
- />
+
+
+ >
+ ) : (
+ <>
+
+ Please review the document before signing.
+
-
-
Signature
+
-
-
- {
- setSignature(value);
- }}
+
+
+
+ Full Name
+
+ setFullName(e.target.value.trimStart())}
/>
-
-
+
+
+
+ Signature
+
+
+
+ {
+ setSignature(value);
+ }}
+ />
+
+
+
+
+
+
+ router.back()}
+ >
+ Cancel
+
+
+
+
-
-
-
- router.back()}
- >
- Cancel
-
-
-
-
-
+ >
+ )}
diff --git a/apps/web/src/app/(signing)/sign/[token]/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/layout.tsx
index cfec41cdf..9db36e8aa 100644
--- a/apps/web/src/app/(signing)/sign/[token]/layout.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/layout.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
+import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';
@@ -12,10 +14,16 @@ export type SigningLayoutProps = {
export default async function SigningLayout({ children }: SigningLayoutProps) {
const { user, session } = await getServerComponentSession();
+ let teams: GetTeamsResponse = [];
+
+ if (user && session) {
+ teams = await getTeams({ userId: user.id });
+ }
+
return (
- {user &&
}
+ {user &&
}
{children}
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx
index 004c59329..7e025593c 100644
--- a/apps/web/src/app/(signing)/sign/[token]/page.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx
@@ -14,7 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
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 { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
+import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
- {document.User.name} ({document.User.email}) has invited you to sign this document.
+ {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.
diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx
index e4d4571fc..a9aedbc3d 100644
--- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx
@@ -1,6 +1,7 @@
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,
@@ -17,6 +18,7 @@ export type SignDialogProps = {
fields: Field[];
fieldsValidated: () => void | Promise;
onSignatureComplete: () => void | Promise;
+ role: RecipientRole;
};
export const SignDialog = ({
@@ -25,6 +27,7 @@ export const SignDialog = ({
fields,
fieldsValidated,
onSignatureComplete,
+ role,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title);
@@ -45,9 +48,18 @@ export const SignDialog = ({
-
Sign Document
+
+ {role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
+ {role === RecipientRole.SIGNER && 'Sign Document'}
+ {role === RecipientRole.APPROVER && 'Approve Document'}
+
- You are about to finish signing "{truncatedTitle}". Are you sure?
+ {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?`}
@@ -71,7 +83,9 @@ export const SignDialog = ({
loading={isSubmitting}
onClick={onSignatureComplete}
>
- Sign
+ {role === RecipientRole.VIEWER && 'Mark as Viewed'}
+ {role === RecipientRole.SIGNER && 'Sign'}
+ {role === RecipientRole.APPROVER && 'Approve'}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
new file mode 100644
index 000000000..b7f610cff
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
@@ -0,0 +1,20 @@
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+
+import DocumentPageComponent 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
;
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
new file mode 100644
index 000000000..952aeeeea
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
@@ -0,0 +1,25 @@
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+
+import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view';
+import DocumentsPageView from '~/app/(dashboard)/documents/documents-page-view';
+
+export type TeamsDocumentPageProps = {
+ params: {
+ teamUrl: string;
+ };
+ searchParams?: DocumentsPageViewProps['searchParams'];
+};
+
+export default async function TeamsDocumentPage({
+ params,
+ searchParams = {},
+}: TeamsDocumentPageProps) {
+ const { teamUrl } = params;
+
+ const { user } = await getRequiredServerComponentSession();
+
+ const team = await getTeamByUrl({ userId: user.id, teamUrl });
+
+ return
;
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
new file mode 100644
index 000000000..1e1eb9921
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+
+import { ChevronLeft } from 'lucide-react';
+
+import { AppErrorCode } from '@documenso/lib/errors/app-error';
+import { Button } from '@documenso/ui/primitives/button';
+
+type ErrorProps = {
+ error: Error & { digest?: string };
+};
+
+export default function ErrorPage({ error }: ErrorProps) {
+ const router = useRouter();
+
+ let errorMessage = 'Unknown error';
+ let errorDetails = '';
+
+ if (error.message === AppErrorCode.UNAUTHORIZED) {
+ errorMessage = 'Unauthorized';
+ errorDetails = 'You are not authorized to view this page.';
+ }
+
+ return (
+
+
+
{errorMessage}
+
+
Oops! Something went wrong.
+
+
{errorDetails}
+
+
+ {
+ void router.back();
+ }}
+ >
+
+ Go Back
+
+
+
+ View teams
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
new file mode 100644
index 000000000..3b4f43031
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import { useState } from 'react';
+
+import { AlertTriangle } from 'lucide-react';
+import { match } from 'ts-pattern';
+
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import type { TeamMemberRole } from '@documenso/prisma/client';
+import { type Subscription, SubscriptionStatus } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogTitle,
+} from '@documenso/ui/primitives/dialog';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type LayoutBillingBannerProps = {
+ subscription: Subscription;
+ teamId: number;
+ userRole: TeamMemberRole;
+};
+
+export const LayoutBillingBanner = ({
+ subscription,
+ teamId,
+ userRole,
+}: LayoutBillingBannerProps) => {
+ const { toast } = useToast();
+
+ const [isOpen, setIsOpen] = useState(false);
+
+ const { mutateAsync: createBillingPortal, isLoading } =
+ trpc.team.createBillingPortal.useMutation();
+
+ const handleCreatePortal = async () => {
+ try {
+ const sessionUrl = await createBillingPortal({ teamId });
+
+ window.open(sessionUrl, '_blank');
+
+ setIsOpen(false);
+ } catch (err) {
+ toast({
+ title: 'Something went wrong',
+ description:
+ 'We are unable to proceed to the billing portal at this time. Please try again, or contact support.',
+ variant: 'destructive',
+ duration: 10000,
+ });
+ }
+ };
+
+ if (subscription.status === SubscriptionStatus.ACTIVE) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {match(subscription.status)
+ .with(SubscriptionStatus.PAST_DUE, () => 'Payment overdue')
+ .with(SubscriptionStatus.INACTIVE, () => 'Teams restricted')
+ .exhaustive()}
+
+
+
setIsOpen(true)}
+ size="sm"
+ >
+ Resolve
+
+
+
+
+
!isLoading && setIsOpen(value)}>
+
+ Payment overdue
+
+ {match(subscription.status)
+ .with(SubscriptionStatus.PAST_DUE, () => (
+
+ Your payment for teams is overdue. Please settle the payment to avoid any service
+ disruptions.
+
+ ))
+ .with(SubscriptionStatus.INACTIVE, () => (
+
+ Due to an unpaid invoice, your team has been restricted. Please settle the payment
+ to restore full access to your team.
+
+ ))
+ .otherwise(() => null)}
+
+ {canExecuteTeamAction('MANAGE_BILLING', userRole) && (
+
+
+ Resolve payment
+
+
+ )}
+
+
+ >
+ );
+};
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
new file mode 100644
index 000000000..2883abc21
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+
+import { RedirectType, redirect } from 'next/navigation';
+
+import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
+import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { getTeams } from '@documenso/lib/server-only/team/get-teams';
+import { SubscriptionStatus } from '@documenso/prisma/client';
+
+import { Header } from '~/components/(dashboard)/layout/header';
+import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
+import { NextAuthProvider } from '~/providers/next-auth';
+
+import { LayoutBillingBanner } from './layout-billing-banner';
+
+export type AuthenticatedTeamsLayoutProps = {
+ children: React.ReactNode;
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function AuthenticatedTeamsLayout({
+ children,
+ params,
+}: AuthenticatedTeamsLayoutProps) {
+ const { session, user } = await getServerComponentSession();
+
+ if (!session || !user) {
+ redirect('/signin');
+ }
+
+ const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([
+ getTeams({ userId: user.id }),
+ getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl }),
+ ]);
+
+ if (getTeamPromise.status === 'rejected') {
+ redirect('/documents', RedirectType.replace);
+ }
+
+ const team = getTeamPromise.value;
+ const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
+
+ return (
+
+
+ {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
+
+ )}
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
new file mode 100644
index 000000000..35962e264
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import Link from 'next/link';
+
+import { ChevronLeft } from 'lucide-react';
+
+import { Button } from '@documenso/ui/primitives/button';
+
+export default function NotFound() {
+ return (
+
+
+
404 Team not found
+
+
Oops! Something went wrong.
+
+
+ The team you are looking for may have been removed, renamed or may have never existed.
+
+
+
+
+
+
+ Go Back
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx
new file mode 100644
index 000000000..1d0e87f79
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx
@@ -0,0 +1,84 @@
+import { DateTime } from 'luxon';
+import type Stripe from 'stripe';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { stripe } from '@documenso/lib/server-only/stripe';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+import { TeamBillingInvoicesDataTable } from '~/components/(teams)/tables/team-billing-invoices-data-table';
+import { TeamBillingPortalButton } from '~/components/(teams)/team-billing-portal-button';
+
+export type TeamsSettingsBillingPageProps = {
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
+ const session = await getRequiredServerComponentSession();
+
+ const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl });
+
+ const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
+
+ let teamSubscription: Stripe.Subscription | null = null;
+
+ if (team.subscription) {
+ teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
+ }
+
+ const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
+ if (!subscription) {
+ return 'No payment required';
+ }
+
+ const numberOfSeats = subscription.items.data[0].quantity ?? 0;
+
+ const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member';
+
+ const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
+ 'LLL dd, yyyy',
+ );
+
+ return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`;
+ };
+
+ return (
+
+
+
+
+
+
+
+ Current plan: {teamSubscription ? 'Team' : 'Community Team'}
+
+
+
+ {formatTeamSubscriptionDetails(teamSubscription)}
+
+
+
+ {teamSubscription && (
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
new file mode 100644
index 000000000..fe2ee5aee
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+
+import { notFound } from 'next/navigation';
+
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+
+import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav';
+import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav';
+
+export type TeamSettingsLayoutProps = {
+ children: React.ReactNode;
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamsSettingsLayout({
+ children,
+ params: { teamUrl },
+}: TeamSettingsLayoutProps) {
+ const session = await getRequiredServerComponentSession();
+
+ try {
+ const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
+
+ if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
+ throw new Error(AppErrorCode.UNAUTHORIZED);
+ }
+ } catch (e) {
+ const error = AppError.parseError(e);
+
+ if (error.code === 'P2025') {
+ notFound();
+ }
+
+ throw e;
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx
new file mode 100644
index 000000000..4617b3d48
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx
@@ -0,0 +1,38 @@
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+import { InviteTeamMembersDialog } from '~/components/(teams)/dialogs/invite-team-member-dialog';
+import { TeamsMemberPageDataTable } from '~/components/(teams)/tables/teams-member-page-data-table';
+
+export type TeamsSettingsMembersPageProps = {
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
+ const { teamUrl } = params;
+
+ const session = await getRequiredServerComponentSession();
+
+ const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
new file mode 100644
index 000000000..a86797191
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
@@ -0,0 +1,186 @@
+import { CheckCircle2, Clock } from 'lucide-react';
+import { P, match } from 'ts-pattern';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import { isTokenExpired } from '@documenso/lib/utils/token-verification';
+import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email-dialog';
+import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog';
+import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog';
+import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form';
+
+import { TeamEmailDropdown } from './team-email-dropdown';
+import { TeamTransferStatus } from './team-transfer-status';
+
+export type TeamsSettingsPageProps = {
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
+ const { teamUrl } = params;
+
+ const session = await getRequiredServerComponentSession();
+
+ const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
+
+ const isTransferVerificationExpired =
+ !team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
+
+ return (
+
+
+
+
+
+
+
+
+ {(team.teamEmail || team.emailVerification) && (
+
+ Team email
+
+
+ You can view documents associated with this email and use this identity when sending
+ documents.
+
+
+
+
+
+
+ {team.teamEmail?.name || team.emailVerification?.name}
+
+ }
+ secondaryText={
+
+ {team.teamEmail?.email || team.emailVerification?.email}
+
+ }
+ />
+
+
+
+ {match({
+ teamEmail: team.teamEmail,
+ emailVerification: team.emailVerification,
+ })
+ .with({ teamEmail: P.not(null) }, () => (
+ <>
+
+ Active
+ >
+ ))
+ .with(
+ {
+ emailVerification: P.when(
+ (emailVerification) =>
+ emailVerification && emailVerification?.expiresAt < new Date(),
+ ),
+ },
+ () => (
+ <>
+
+ Expired
+ >
+ ),
+ )
+ .with({ emailVerification: P.not(null) }, () => (
+ <>
+
+ Awaiting email confirmation
+ >
+ ))
+ .otherwise(() => null)}
+
+
+
+
+
+
+ )}
+
+ {!team.teamEmail && !team.emailVerification && (
+
+
+
Team email
+
+
+
+ {/* Feature not available yet. */}
+ {/* Display this name and email when sending documents */}
+ {/* View documents associated with this email */}
+
+ View documents associated with this email
+
+
+
+
+
+
+ )}
+
+ {team.ownerUserId === session.user.id && (
+ <>
+ {isTransferVerificationExpired && (
+
+
+
Transfer team
+
+
+ Transfer the ownership of the team to another team member.
+
+
+
+
+
+ )}
+
+
+
+
Delete team
+
+
+ This team, and any associated data excluding billing invoices will be permanently
+ deleted.
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx
new file mode 100644
index 000000000..e2c0a0d87
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
+
+import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { trpc } from '@documenso/trpc/react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { UpdateTeamEmailDialog } from '~/components/(teams)/dialogs/update-team-email-dialog';
+
+export type TeamsSettingsPageProps = {
+ team: Awaited
>;
+};
+
+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 (
+
+
+
+
+
+
+ {!team.teamEmail && team.emailVerification && (
+ {
+ e.preventDefault();
+ void resendEmailVerification({ teamId: team.id });
+ }}
+ >
+ {isResendingEmailVerification ? (
+
+ ) : (
+
+ )}
+ Resend verification
+
+ )}
+
+ {team.teamEmail && (
+ e.preventDefault()}>
+
+ Edit
+
+ }
+ />
+ )}
+
+ onRemove()}
+ >
+
+ Remove
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx
new file mode 100644
index 000000000..cba50966f
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+import { AnimatePresence } from 'framer-motion';
+
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import { isTokenExpired } from '@documenso/lib/utils/token-verification';
+import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
+import { cn } from '@documenso/ui/lib/utils';
+import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type TeamTransferStatusProps = {
+ className?: string;
+ currentUserTeamRole: TeamMemberRole;
+ teamId: number;
+ transferVerification: TeamTransferVerification | 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 (
+
+ {transferVerification && (
+
+
+
+
+ {isExpired ? 'Team transfer request expired' : 'Team transfer in progress'}
+
+
+
+ {isExpired ? (
+
+ The team transfer request to {transferVerification.name} has
+ expired.
+
+ ) : (
+
+
+ A request to transfer the ownership of this team has been sent to{' '}
+
+ {transferVerification.name} ({transferVerification.email})
+
+
+
+
+ If they accept this request, the team will be transferred to their account.
+
+
+ )}
+
+
+
+ {canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && (
+ deleteTeamTransferRequest({ teamId })}
+ loading={isLoading}
+ variant={isExpired ? 'destructive' : 'ghost'}
+ className={cn('ml-auto', {
+ 'hover:bg-transparent hover:text-blue-800': !isExpired,
+ })}
+ >
+ {isExpired ? 'Close' : 'Cancel'}
+
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/app/(unauthenticated)/check-email/page.tsx b/apps/web/src/app/(unauthenticated)/check-email/page.tsx
index fffbc44c1..94b410a8e 100644
--- a/apps/web/src/app/(unauthenticated)/check-email/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/check-email/page.tsx
@@ -1,7 +1,12 @@
+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 (
diff --git a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
index 20ecddf4d..36c023027 100644
--- a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
@@ -1,7 +1,12 @@
+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 (
diff --git a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx
index c4f521363..93cd41ebb 100644
--- a/apps/web/src/app/(unauthenticated)/reset-password/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/reset-password/page.tsx
@@ -1,7 +1,12 @@
+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 (
diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx
index 5fda07e70..8331e7c03 100644
--- a/apps/web/src/app/(unauthenticated)/signin/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx
@@ -1,10 +1,30 @@
+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 default function SignInPage() {
+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');
+ }
+
return (
Sign in to your account
@@ -13,7 +33,11 @@ export default function SignInPage() {
Welcome back, we are lucky to have you.
-
+
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx
index 05b9caf21..dbbbcdba9 100644
--- a/apps/web/src/app/(unauthenticated)/signup/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx
@@ -1,15 +1,34 @@
+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 default function SignUpPage() {
+export const metadata: Metadata = {
+ title: 'Sign Up',
+};
+
+type SignUpPageProps = {
+ searchParams: {
+ email?: string;
+ };
+};
+
+export default function SignUpPage({ searchParams }: SignUpPageProps) {
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 (
Create a new account
@@ -19,7 +38,11 @@ export default function SignUpPage() {
signing is within your grasp.
-
+
Already have an account?{' '}
diff --git a/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
new file mode 100644
index 000000000..634416fe3
--- /dev/null
+++ b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
@@ -0,0 +1,121 @@
+import Link from 'next/link';
+
+import { DateTime } from 'luxon';
+
+import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
+import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
+import { getTeamById } from '@documenso/lib/server-only/team/get-team';
+import { prisma } from '@documenso/prisma';
+import { TeamMemberInviteStatus } from '@documenso/prisma/client';
+import { Button } from '@documenso/ui/primitives/button';
+
+type AcceptInvitationPageProps = {
+ params: {
+ token: string;
+ };
+};
+
+export default async function AcceptInvitationPage({
+ params: { token },
+}: AcceptInvitationPageProps) {
+ const session = await getServerComponentSession();
+
+ const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
+ where: {
+ token,
+ },
+ });
+
+ if (!teamMemberInvite) {
+ return (
+
+
Invalid token
+
+
+ This token is invalid or has expired. Please contact your team for a new invitation.
+
+
+
+ Return
+
+
+ );
+ }
+
+ 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 (
+
+
Team invitation
+
+
+ You have been invited by {team.name} to join their team.
+
+
+
+ To accept this invitation you must create an account.
+
+
+
+ Create account
+
+
+ );
+ }
+
+ const isSessionUserTheInvitedUser = user.id === session.user?.id;
+
+ return (
+
+
Invitation accepted!
+
+
+ You have accepted an invitation from {team.name} to join their team.
+
+
+ {isSessionUserTheInvitedUser ? (
+
+ Continue
+
+ ) : (
+
+ Continue to login
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx
new file mode 100644
index 000000000..53ad4461b
--- /dev/null
+++ b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx
@@ -0,0 +1,89 @@
+import Link from 'next/link';
+
+import { isTokenExpired } from '@documenso/lib/utils/token-verification';
+import { prisma } from '@documenso/prisma';
+import { Button } from '@documenso/ui/primitives/button';
+
+type VerifyTeamEmailPageProps = {
+ params: {
+ token: string;
+ };
+};
+
+export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) {
+ const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
+ where: {
+ token,
+ },
+ include: {
+ team: true,
+ },
+ });
+
+ if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
+ return (
+
+
Invalid link
+
+
+ This link is invalid or has expired. Please contact your team to resend a verification.
+
+
+
+ Return
+
+
+ );
+ }
+
+ 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 (
+
+
Team email verification
+
+
+ Something went wrong while attempting to verify your email address for{' '}
+ {team.name} . Please try again later.
+
+
+ );
+ }
+
+ return (
+
+
Team email verified!
+
+
+ You have verified your email address for {team.name} .
+
+
+
+ Continue
+
+
+ );
+}
diff --git a/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx
new file mode 100644
index 000000000..819b7e970
--- /dev/null
+++ b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx
@@ -0,0 +1,80 @@
+import Link from 'next/link';
+
+import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership';
+import { isTokenExpired } from '@documenso/lib/utils/token-verification';
+import { prisma } from '@documenso/prisma';
+import { Button } from '@documenso/ui/primitives/button';
+
+type VerifyTeamTransferPage = {
+ params: {
+ token: string;
+ };
+};
+
+export default async function VerifyTeamTransferPage({
+ params: { token },
+}: VerifyTeamTransferPage) {
+ const teamTransferVerification = await prisma.teamTransferVerification.findUnique({
+ where: {
+ token,
+ },
+ include: {
+ team: true,
+ },
+ });
+
+ if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
+ return (
+
+
Invalid link
+
+
+ This link is invalid or has expired. Please contact your team to resend a transfer
+ request.
+
+
+
+ Return
+
+
+ );
+ }
+
+ const { team } = teamTransferVerification;
+
+ let isTransferError = false;
+
+ try {
+ await transferTeamOwnership({ token });
+ } catch (e) {
+ console.error(e);
+ isTransferError = true;
+ }
+
+ if (isTransferError) {
+ return (
+
+
Team ownership transfer
+
+
+ Something went wrong while attempting to transfer the ownership of team{' '}
+ {team.name} to your. Please try again later or contact support.
+
+
+ );
+ }
+
+ return (
+
+
Team ownership transferred!
+
+
+ The ownership of team {team.name} has been successfully transferred to you.
+
+
+
+ Continue
+
+
+ );
+}
diff --git a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx
index 04202d19b..30d2baf16 100644
--- a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx
@@ -1,9 +1,14 @@
+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 (
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index ac88469b0..17f92fa2b 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -20,7 +20,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
- title: 'Documenso - The Open Source DocuSign Alternative',
+ title: {
+ template: '%s - Documenso',
+ default: 'Documenso',
+ },
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx
index d04b3a998..46182c36e 100644
--- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx
+++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx
@@ -4,6 +4,7 @@ import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
+import { 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';
@@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
-
-
{recipient.email}
+
+
+
{recipient.email}
+
+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
+
+
+
);
}
diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx
index 7429d8ee5..bd7bea2b0 100644
--- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx
+++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx
@@ -1,4 +1,5 @@
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 {
@@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
-
{recipient.email}
+
+
{recipient.email}
+
+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
+
+
))}
diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx
index 0312a96d2..3fe42a4c4 100644
--- a/apps/web/src/components/(dashboard)/common/command-menu.tsx
+++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx
@@ -197,20 +197,22 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
)}
{!currentPage && (
<>
-
+
-
+
-
+
-
- addPage('theme')}>Change theme
+
+ addPage('theme')}>
+ Change theme
+
{searchResults.length > 0 && (
-
+
)}
@@ -231,6 +233,7 @@ const Commands = ({
}) => {
return pages.map((page, idx) => (
push(page.path)}
@@ -255,7 +258,7 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
setTheme(theme.theme)}
- className="mx-2 first:mt-2 last:mb-2"
+ className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
>
{theme.label}
diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
index e04bc2818..2b11c4be2 100644
--- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
@@ -4,10 +4,11 @@ import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
-import { usePathname } from 'next/navigation';
+import { useParams, usePathname } from 'next/navigation';
import { Search } from 'lucide-react';
+import { getRootHref } from '@documenso/lib/utils/params';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -28,10 +29,13 @@ export type DesktopNavProps = HTMLAttributes;
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);
@@ -48,20 +52,24 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
{...props}
>
- {navigationLinks.map(({ href, label }) => (
-
- {label}
-
- ))}
+ {navigationLinks
+ .filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages.
+ .map(({ href, label }) => (
+
+ {label}
+
+ ))}
diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx
index bdae6c511..753f5fb11 100644
--- a/apps/web/src/components/(dashboard)/layout/header.tsx
+++ b/apps/web/src/components/(dashboard)/layout/header.tsx
@@ -1,23 +1,34 @@
'use client';
-import type { HTMLAttributes } from 'react';
-import { useEffect, useState } from 'react';
+import { type HTMLAttributes, useEffect, useState } from 'react';
import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { MenuIcon, SearchIcon } from 'lucide-react';
+
+import 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 { ProfileDropdown } from './profile-dropdown';
+import { MenuSwitcher } from './menu-switcher';
+import { MobileNavigation } from './mobile-navigation';
export type HeaderProps = HTMLAttributes & {
user: User;
+ teams: GetTeamsResponse;
};
-export const Header = ({ className, user, ...props }: HeaderProps) => {
+export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
+ const params = useParams();
+
+ const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
+ const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
@@ -33,7 +44,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
return (
diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
new file mode 100644
index 000000000..35a05baf2
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
@@ -0,0 +1,214 @@
+'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 } 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];
+ };
+
+ return (
+
+
+
+
+ }
+ />
+
+
+
+
+ {teams ? (
+ <>
+ Personal
+
+
+
+
+ )
+ }
+ />
+
+
+
+
+
+
+
+
Teams
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {teams.map((team) => (
+
+
+
+ )
+ }
+ />
+
+
+ ))}
+ >
+ ) : (
+
+
+ Create team
+
+
+
+ )}
+
+
+
+ {isUserAdmin && (
+
+ Admin panel
+
+ )}
+
+
+ User settings
+
+
+ {selectedTeam &&
+ canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
+
+ Team settings
+
+ )}
+
+
+ signOut({
+ callbackUrl: '/',
+ })
+ }
+ >
+ Sign Out
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(dashboard)/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/layout/mobile-nav.tsx
deleted file mode 100644
index e69de29bb..000000000
diff --git a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx
new file mode 100644
index 000000000..7142de5dc
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+
+import { signOut } from 'next-auth/react';
+
+import LogoImage from '@documenso/assets/logo.png';
+import { getRootHref } from '@documenso/lib/utils/params';
+import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
+import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
+
+export type MobileNavigationProps = {
+ isMenuOpen: boolean;
+ onMenuOpenChange?: (_value: boolean) => void;
+};
+
+export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
+ const params = useParams();
+
+ const handleMenuItemClick = () => {
+ onMenuOpenChange?.(false);
+ };
+
+ const rootHref = getRootHref(params, { returnEmptyRootString: true });
+
+ const menuNavigationLinks = [
+ {
+ href: `${rootHref}/documents`,
+ text: 'Documents',
+ },
+ {
+ href: `${rootHref}/templates`,
+ text: 'Templates',
+ },
+ {
+ href: '/settings/teams',
+ text: 'Teams',
+ },
+ {
+ href: '/settings/profile',
+ text: 'Settings',
+ },
+ ].filter(({ text, href }) => text !== 'Templates' || href === '/templates'); // Filter out templates for teams.
+
+ return (
+
+
+
+
+
+
+
+ {menuNavigationLinks.map(({ href, text }) => (
+ handleMenuItemClick()}
+ >
+ {text}
+
+ ))}
+
+
+ signOut({
+ callbackUrl: '/',
+ })
+ }
+ >
+ Sign Out
+
+
+
+
+
+
+
+
+
+ © {new Date().getFullYear()} Documenso, Inc. All rights reserved.
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
deleted file mode 100644
index 252432b89..000000000
--- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-
-import {
- CreditCard,
- FileSpreadsheet,
- Lock,
- LogOut,
- User as LucideUser,
- Monitor,
- Moon,
- Palette,
- Sun,
- UserCog,
-} from 'lucide-react';
-import { signOut } from 'next-auth/react';
-import { useTheme } from 'next-themes';
-import { LuGithub } from 'react-icons/lu';
-
-import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
-import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
-import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
-import type { User } from '@documenso/prisma/client';
-import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
-import { Button } from '@documenso/ui/primitives/button';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuPortal,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from '@documenso/ui/primitives/dropdown-menu';
-
-export type ProfileDropdownProps = {
- user: User;
-};
-
-export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
- const { getFlag } = useFeatureFlags();
- const { theme, setTheme } = useTheme();
- const isUserAdmin = isAdmin(user);
-
- const isBillingEnabled = getFlag('app_billing');
-
- const avatarFallback = user.name
- ? recipientInitials(user.name)
- : user.email.slice(0, 1).toUpperCase();
-
- return (
-
-
-
-
- {avatarFallback}
-
-
-
-
-
- Account
-
- {isUserAdmin && (
- <>
-
-
-
- Admin
-
-
-
-
- >
- )}
-
-
-
-
- Profile
-
-
-
-
-
-
- Security
-
-
-
- {isBillingEnabled && (
-
-
-
- Billing
-
-
- )}
-
-
-
-
-
- Templates
-
-
-
-
-
-
-
- Themes
-
-
-
-
-
- Light
-
-
-
- Dark
-
-
-
- System
-
-
-
-
-
-
-
-
-
- Star on Github
-
-
-
-
-
-
- void signOut({
- callbackUrl: '/',
- })
- }
- >
-
- Sign Out
-
-
-
- );
-};
diff --git a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx b/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx
index caeb780d0..a49e2f284 100644
--- a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx
+++ b/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx
@@ -21,9 +21,9 @@ export const PeriodSelector = () => {
const router = useRouter();
const period = useMemo(() => {
- const p = searchParams?.get('period') ?? '';
+ const p = searchParams?.get('period') ?? 'all';
- return isPeriodSelectorValue(p) ? p : '';
+ return isPeriodSelectorValue(p) ? p : 'all';
}, [searchParams]);
const onPeriodChange = (newPeriod: string) => {
@@ -35,7 +35,7 @@ export const PeriodSelector = () => {
params.set('period', newPeriod);
- if (newPeriod === '') {
+ if (newPeriod === '' || newPeriod === 'all') {
params.delete('period');
}
@@ -49,7 +49,7 @@ export const PeriodSelector = () => {
- All Time
+ All Time
Last 7 days
Last 14 days
Last 30 days
diff --git a/apps/web/src/components/(dashboard)/period-selector/types.ts b/apps/web/src/components/(dashboard)/period-selector/types.ts
index 2b50f5d6c..8ae1c5fbe 100644
--- a/apps/web/src/components/(dashboard)/period-selector/types.ts
+++ b/apps/web/src/components/(dashboard)/period-selector/types.ts
@@ -1,4 +1,4 @@
-export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
+import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
index f4b2aae5e..c7ab61d8a 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
@@ -1,11 +1,11 @@
'use client';
-import { HTMLAttributes } from 'react';
+import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { CreditCard, Lock, User } from 'lucide-react';
+import { CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+
+
+
+ Teams
+
+
+
{
+ return (
+ <>
+
+
+
{title}
+
+
{subtitle}
+
+
+ {children}
+
+
+
+ >
+ );
+};
diff --git a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
index 28ffc960f..2809cefe5 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
@@ -1,11 +1,11 @@
'use client';
-import { HTMLAttributes } from 'react';
+import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { CreditCard, Lock, User } from 'lucide-react';
+import { CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -38,6 +38,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
+
+
+
+ Teams
+
+
+
;
+
+const ZCreateTeamEmailFormSchema = ZCreateTeamEmailVerificationMutationSchema.pick({
+ name: true,
+ email: true,
+});
+
+type TCreateTeamEmailFormSchema = z.infer;
+
+export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDialogProps) => {
+ const router = useRouter();
+
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZCreateTeamEmailFormSchema),
+ defaultValues: {
+ name: '',
+ email: '',
+ },
+ });
+
+ const { mutateAsync: createTeamEmailVerification, isLoading } =
+ trpc.team.createTeamEmailVerification.useMutation();
+
+ const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
+ try {
+ await createTeamEmailVerification({
+ teamId,
+ name,
+ email,
+ });
+
+ toast({
+ title: 'Success',
+ description: 'We have sent a confirmation email for verification.',
+ duration: 5000,
+ });
+
+ router.refresh();
+
+ setOpen(false);
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.ALREADY_EXISTS) {
+ form.setError('email', {
+ type: 'manual',
+ message: 'This email is already being used by another team.',
+ });
+
+ return;
+ }
+
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to add this email. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild={true}>
+ {trigger ?? (
+
+
+ Add email
+
+ )}
+
+
+
+
+ Add team email
+
+
+ A verification email will be sent to the provided email.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx
new file mode 100644
index 000000000..f7ee8ca51
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx
@@ -0,0 +1,177 @@
+import { useMemo, useState } from 'react';
+
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { AnimatePresence, motion } from 'framer-motion';
+import { Loader, TagIcon } from 'lucide-react';
+
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@documenso/ui/primitives/dialog';
+import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type CreateTeamCheckoutDialogProps = {
+ pendingTeamId: number | null;
+ onClose: () => void;
+} & Omit;
+
+const MotionCard = motion(Card);
+
+export const CreateTeamCheckoutDialog = ({
+ pendingTeamId,
+ onClose,
+ ...props
+}: CreateTeamCheckoutDialogProps) => {
+ const { toast } = useToast();
+
+ const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
+
+ const { data, isLoading } = trpc.team.getTeamPrices.useQuery();
+
+ const { mutateAsync: createCheckout, isLoading: isCreatingCheckout } =
+ trpc.team.createTeamPendingCheckout.useMutation({
+ onSuccess: (checkoutUrl) => {
+ window.open(checkoutUrl, '_blank');
+ onClose();
+ },
+ onError: () =>
+ toast({
+ title: 'Something went wrong',
+ description:
+ 'We were unable to create a checkout session. Please try again, or contact support',
+ variant: 'destructive',
+ }),
+ });
+
+ const selectedPrice = useMemo(() => {
+ if (!data) {
+ return null;
+ }
+
+ return data[interval];
+ }, [data, interval]);
+
+ const handleOnOpenChange = (open: boolean) => {
+ if (pendingTeamId === null) {
+ return;
+ }
+
+ if (!open) {
+ onClose();
+ }
+ };
+
+ if (pendingTeamId === null) {
+ return null;
+ }
+
+ return (
+
+
+
+ Team checkout
+
+
+ Payment is required to finalise the creation of your team.
+
+
+
+ {(isLoading || !data) && (
+
+ {isLoading ? (
+
+ ) : (
+
Something went wrong
+ )}
+
+ )}
+
+ {data && selectedPrice && !isLoading && (
+
+
setInterval(value as 'monthly' | 'yearly')}
+ value={interval}
+ className="mb-4"
+ >
+
+ {[data.monthly, data.yearly].map((price) => (
+
+ {price.friendlyInterval}
+
+ ))}
+
+
+
+
+
+
+ {selectedPrice.interval === 'monthly' ? (
+
+ $50 USD per month
+
+ ) : (
+
+
+ $480 USD per year
+
+
+
+ 20% off
+
+
+ )}
+
+
+
This price includes minimum 5 seats.
+
+
+ Adding and removing seats will adjust your invoice accordingly.
+
+
+
+
+
+
+
+ onClose()}
+ >
+ Cancel
+
+
+
+ createCheckout({
+ interval: selectedPrice.interval,
+ pendingTeamId,
+ })
+ }
+ >
+ Checkout
+
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx
new file mode 100644
index 000000000..283fd8dad
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx
@@ -0,0 +1,223 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { useForm } from 'react-hook-form';
+import type { z } from 'zod';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { trpc } from '@documenso/trpc/react';
+import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type CreateTeamDialogProps = {
+ trigger?: React.ReactNode;
+} & Omit;
+
+const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
+ teamName: true,
+ teamUrl: true,
+});
+
+type TCreateTeamFormSchema = z.infer;
+
+export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) => {
+ const { toast } = useToast();
+
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const [open, setOpen] = useState(false);
+
+ const actionSearchParam = searchParams?.get('action');
+
+ const form = useForm({
+ resolver: zodResolver(ZCreateTeamFormSchema),
+ defaultValues: {
+ teamName: '',
+ teamUrl: '',
+ },
+ });
+
+ const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
+
+ const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => {
+ try {
+ const response = await createTeam({
+ teamName,
+ teamUrl,
+ });
+
+ setOpen(false);
+
+ if (response.paymentRequired) {
+ router.push(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
+ return;
+ }
+
+ toast({
+ title: 'Success',
+ description: 'Your team has been created.',
+ duration: 5000,
+ });
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.ALREADY_EXISTS) {
+ form.setError('teamUrl', {
+ type: 'manual',
+ message: 'This URL is already in use.',
+ });
+
+ return;
+ }
+
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to create a team. Please try again later.',
+ });
+ }
+ };
+
+ const mapTextToUrl = (text: string) => {
+ return text.toLowerCase().replace(/\s+/g, '-');
+ };
+
+ useEffect(() => {
+ if (actionSearchParam === 'add-team') {
+ setOpen(true);
+ updateSearchParams({ action: null });
+ }
+ }, [actionSearchParam, open, setOpen, updateSearchParams]);
+
+ useEffect(() => {
+ form.reset();
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild={true}>
+ {trigger ?? (
+
+ Create team
+
+ )}
+
+
+
+
+ Create team
+
+
+ Create a team to collaborate with your team members.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx
new file mode 100644
index 000000000..99630e57c
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx
@@ -0,0 +1,160 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { AppError } from '@documenso/lib/errors/app-error';
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import type { Toast } from '@documenso/ui/primitives/use-toast';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type DeleteTeamDialogProps = {
+ teamId: number;
+ teamName: string;
+ trigger?: React.ReactNode;
+};
+
+export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialogProps) => {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const deleteMessage = `delete ${teamName}`;
+
+ const ZDeleteTeamFormSchema = z.object({
+ teamName: z.literal(deleteMessage, {
+ errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
+ }),
+ });
+
+ const form = useForm({
+ resolver: zodResolver(ZDeleteTeamFormSchema),
+ defaultValues: {
+ teamName: '',
+ },
+ });
+
+ const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation();
+
+ const onFormSubmit = async () => {
+ try {
+ await deleteTeam({ teamId });
+
+ toast({
+ title: 'Success',
+ description: 'Your team has been successfully deleted.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+
+ router.push('/settings/teams');
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ let toastError: Toast = {
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to delete this team. Please try again later.',
+ };
+
+ if (error.code === 'resource_missing') {
+ toastError = {
+ title: 'Unable to delete team',
+ variant: 'destructive',
+ duration: 15000,
+ description:
+ 'Something went wrong while updating the team billing subscription, please contact support.',
+ };
+ }
+
+ toast(toastError);
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}>
+
+ {trigger ?? Delete team }
+
+
+
+
+ Delete team
+
+
+ Are you sure? This is irreversable.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx
new file mode 100644
index 000000000..7ae8ccf1c
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx
@@ -0,0 +1,107 @@
+'use client';
+
+import { useState } from 'react';
+
+import { trpc } from '@documenso/trpc/react';
+import { Alert } from '@documenso/ui/primitives/alert';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+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 DeleteTeamMemberDialogProps = {
+ teamId: number;
+ teamName: string;
+ teamMemberId: number;
+ teamMemberName: string;
+ teamMemberEmail: string;
+ trigger?: React.ReactNode;
+};
+
+export const DeleteTeamMemberDialog = ({
+ trigger,
+ teamId,
+ teamName,
+ teamMemberId,
+ teamMemberName,
+ teamMemberEmail,
+}: DeleteTeamMemberDialogProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const { mutateAsync: deleteTeamMembers, isLoading: isDeletingTeamMember } =
+ trpc.team.deleteTeamMembers.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'You have successfully removed this user from the team.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+ },
+ onError: () => {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to remove this user. Please try again later.',
+ });
+ },
+ });
+
+ return (
+ !isDeletingTeamMember && setOpen(value)}>
+
+ {trigger ?? Delete team member }
+
+
+
+
+ Are you sure?
+
+
+ You are about to remove the following user from{' '}
+ {teamName} .
+
+
+
+
+ {teamMemberName}}
+ secondaryText={teamMemberEmail}
+ />
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })}
+ >
+ Delete
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx
new file mode 100644
index 000000000..482142c99
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx
@@ -0,0 +1,244 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { Mail, PlusCircle, Trash } from 'lucide-react';
+import { useFieldArray, useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { TeamMemberRole } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type InviteTeamMembersDialogProps = {
+ currentUserTeamRole: TeamMemberRole;
+ teamId: number;
+ trigger?: React.ReactNode;
+} & Omit;
+
+const ZInviteTeamMembersFormSchema = z
+ .object({
+ invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
+ })
+ .refine(
+ (schema) => {
+ const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase());
+
+ return new Set(emails).size === emails.length;
+ },
+ // Dirty hack to handle errors when .root is populated for an array type
+ { message: 'Members must have unique emails', path: ['members__root'] },
+ );
+
+type TInviteTeamMembersFormSchema = z.infer;
+
+export const InviteTeamMembersDialog = ({
+ currentUserTeamRole,
+ teamId,
+ trigger,
+ ...props
+}: InviteTeamMembersDialogProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZInviteTeamMembersFormSchema),
+ defaultValues: {
+ invitations: [
+ {
+ email: '',
+ role: TeamMemberRole.MEMBER,
+ },
+ ],
+ },
+ });
+
+ const {
+ append: appendTeamMemberInvite,
+ fields: teamMemberInvites,
+ remove: removeTeamMemberInvite,
+ } = useFieldArray({
+ control: form.control,
+ name: 'invitations',
+ });
+
+ const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation();
+
+ const onAddTeamMemberInvite = () => {
+ appendTeamMemberInvite({
+ email: '',
+ role: TeamMemberRole.MEMBER,
+ });
+ };
+
+ const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
+ try {
+ await createTeamMemberInvites({
+ teamId,
+ invitations,
+ });
+
+ toast({
+ title: 'Success',
+ description: 'Team invitations have been sent.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+ } catch {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to invite team members. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild>
+ {trigger ?? Invite member }
+
+
+
+
+ Invite team members
+
+
+ An email containing an invitation will be sent to each member.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx
new file mode 100644
index 000000000..27384d680
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import { useState } from 'react';
+
+import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import type { TeamMemberRole } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { Alert } from '@documenso/ui/primitives/alert';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+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 LeaveTeamDialogProps = {
+ teamId: number;
+ teamName: string;
+ role: TeamMemberRole;
+ trigger?: React.ReactNode;
+};
+
+export const LeaveTeamDialog = ({ trigger, teamId, teamName, role }: LeaveTeamDialogProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const { mutateAsync: leaveTeam, isLoading: isLeavingTeam } = trpc.team.leaveTeam.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'You have successfully left this team.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+ },
+ onError: () => {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to leave this team. Please try again later.',
+ });
+ },
+ });
+
+ return (
+ !isLeavingTeam && setOpen(value)}>
+
+ {trigger ?? Leave team }
+
+
+
+
+ Are you sure?
+
+
+ You are about to leave the following team.
+
+
+
+
+
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ leaveTeam({ teamId })}
+ >
+ Leave
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx
new file mode 100644
index 000000000..e5dd8ca17
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx
@@ -0,0 +1,293 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Loader } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { trpc } from '@documenso/trpc/react';
+import { Alert, AlertDescription } 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 {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type TransferTeamDialogProps = {
+ teamId: number;
+ teamName: string;
+ ownerUserId: number;
+ trigger?: React.ReactNode;
+};
+
+export const TransferTeamDialog = ({
+ trigger,
+ teamId,
+ teamName,
+ ownerUserId,
+}: TransferTeamDialogProps) => {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const { mutateAsync: requestTeamOwnershipTransfer } =
+ trpc.team.requestTeamOwnershipTransfer.useMutation();
+
+ const {
+ data,
+ refetch: refetchTeamMembers,
+ isLoading: loadingTeamMembers,
+ isLoadingError: loadingTeamMembersError,
+ } = trpc.team.getTeamMembers.useQuery({
+ teamId,
+ });
+
+ const confirmTransferMessage = `transfer ${teamName}`;
+
+ const ZTransferTeamFormSchema = z.object({
+ teamName: z.literal(confirmTransferMessage, {
+ errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }),
+ }),
+ newOwnerUserId: z.string(),
+ clearPaymentMethods: z.boolean(),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(ZTransferTeamFormSchema),
+ defaultValues: {
+ teamName: '',
+ clearPaymentMethods: false,
+ },
+ });
+
+ const onFormSubmit = async ({
+ newOwnerUserId,
+ clearPaymentMethods,
+ }: z.infer) => {
+ try {
+ await requestTeamOwnershipTransfer({
+ teamId,
+ newOwnerUserId: Number.parseInt(newOwnerUserId),
+ clearPaymentMethods,
+ });
+
+ router.refresh();
+
+ toast({
+ title: 'Success',
+ description: 'An email requesting the transfer of this team has been sent.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+ } catch (err) {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to request a transfer of this team. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ useEffect(() => {
+ if (open && loadingTeamMembersError) {
+ void refetchTeamMembers();
+ }
+ }, [open, loadingTeamMembersError, refetchTeamMembers]);
+
+ const teamMembers = data
+ ? data.filter((teamMember) => teamMember.userId !== ownerUserId)
+ : undefined;
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}>
+
+ {trigger ?? (
+
+ Transfer team
+
+ )}
+
+
+ {teamMembers && teamMembers.length > 0 ? (
+
+
+ Transfer team
+
+
+ Transfer ownership of this team to a selected team member.
+
+
+
+
+
+
+ ) : (
+
+ {loadingTeamMembers ? (
+
+ ) : (
+
+ {loadingTeamMembersError
+ ? 'An error occurred while loading team members. Please try again later.'
+ : 'You must have at least one other team member to transfer ownership.'}
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx
new file mode 100644
index 000000000..c6ab8890a
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import type { TeamEmail } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type UpdateTeamEmailDialogProps = {
+ teamEmail: TeamEmail;
+ trigger?: React.ReactNode;
+} & Omit;
+
+const ZUpdateTeamEmailFormSchema = z.object({
+ name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
+});
+
+type TUpdateTeamEmailFormSchema = z.infer;
+
+export const UpdateTeamEmailDialog = ({
+ teamEmail,
+ trigger,
+ ...props
+}: UpdateTeamEmailDialogProps) => {
+ const router = useRouter();
+
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZUpdateTeamEmailFormSchema),
+ defaultValues: {
+ name: teamEmail.name,
+ },
+ });
+
+ const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation();
+
+ const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
+ try {
+ await updateTeamEmail({
+ teamId: teamEmail.teamId,
+ data: {
+ name,
+ },
+ });
+
+ toast({
+ title: 'Success',
+ description: 'Team email was updated.',
+ duration: 5000,
+ });
+
+ router.refresh();
+
+ setOpen(false);
+ } catch (err) {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting update the team email. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild>
+ {trigger ?? (
+
+ Update team email
+
+ )}
+
+
+
+
+ Update team email
+
+
+ To change the email you must remove and add a new email address.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx
new file mode 100644
index 000000000..cc8ea675f
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx
@@ -0,0 +1,185 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
+import { TeamMemberRole } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type UpdateTeamMemberDialogProps = {
+ currentUserTeamRole: TeamMemberRole;
+ trigger?: React.ReactNode;
+ teamId: number;
+ teamMemberId: number;
+ teamMemberName: string;
+ teamMemberRole: TeamMemberRole;
+} & Omit;
+
+const ZUpdateTeamMemberFormSchema = z.object({
+ role: z.nativeEnum(TeamMemberRole),
+});
+
+type ZUpdateTeamMemberSchema = z.infer;
+
+export const UpdateTeamMemberDialog = ({
+ currentUserTeamRole,
+ trigger,
+ teamId,
+ teamMemberId,
+ teamMemberName,
+ teamMemberRole,
+ ...props
+}: UpdateTeamMemberDialogProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZUpdateTeamMemberFormSchema),
+ defaultValues: {
+ role: teamMemberRole,
+ },
+ });
+
+ const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation();
+
+ const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
+ try {
+ await updateTeamMember({
+ teamId,
+ teamMemberId,
+ data: {
+ role,
+ },
+ });
+
+ toast({
+ title: 'Success',
+ description: `You have updated ${teamMemberName}.`,
+ duration: 5000,
+ });
+
+ setOpen(false);
+ } catch {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to update this team member. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ form.reset();
+
+ if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) {
+ setOpen(false);
+
+ toast({
+ title: 'You cannot modify a team member who has a higher role than you.',
+ variant: 'destructive',
+ });
+ }
+ }, [open, currentUserTeamRole, teamMemberRole, form, toast]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild>
+ {trigger ?? Update team member }
+
+
+
+
+ Update team member
+
+
+ You are currently updating {teamMemberName}.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/forms/update-team-form.tsx b/apps/web/src/components/(teams)/forms/update-team-form.tsx
new file mode 100644
index 000000000..142914b8c
--- /dev/null
+++ b/apps/web/src/components/(teams)/forms/update-team-form.tsx
@@ -0,0 +1,173 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { AnimatePresence, motion } from 'framer-motion';
+import { useForm } from 'react-hook-form';
+import type { z } from 'zod';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { trpc } from '@documenso/trpc/react';
+import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type UpdateTeamDialogProps = {
+ teamId: number;
+ teamName: string;
+ teamUrl: string;
+};
+
+const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
+ name: true,
+ url: true,
+});
+
+type TUpdateTeamFormSchema = z.infer;
+
+export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
+ const router = useRouter();
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZUpdateTeamFormSchema),
+ defaultValues: {
+ name: teamName,
+ url: teamUrl,
+ },
+ });
+
+ const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
+
+ const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
+ try {
+ await updateTeam({
+ data: {
+ name,
+ url,
+ },
+ teamId,
+ });
+
+ toast({
+ title: 'Success',
+ description: 'Your team has been successfully updated.',
+ duration: 5000,
+ });
+
+ form.reset({
+ name,
+ url,
+ });
+
+ if (url !== teamUrl) {
+ router.push(`${WEBAPP_BASE_URL}/t/${url}/settings`);
+ }
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.ALREADY_EXISTS) {
+ form.setError('url', {
+ type: 'manual',
+ message: 'This URL is already in use.',
+ });
+
+ return;
+ }
+
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to update your team. Please try again later.',
+ });
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx
new file mode 100644
index 000000000..be68f6c03
--- /dev/null
+++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import type { HTMLAttributes } from 'react';
+
+import Link from 'next/link';
+import { useParams, usePathname } from 'next/navigation';
+
+import { CreditCard, Settings, Users } from 'lucide-react';
+
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+
+export type DesktopNavProps = HTMLAttributes;
+
+export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+ const pathname = usePathname();
+ const params = useParams();
+
+ const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
+
+ const settingsPath = `/t/${teamUrl}/settings`;
+ const membersPath = `/t/${teamUrl}/settings/members`;
+ const billingPath = `/t/${teamUrl}/settings/billing`;
+
+ return (
+
+
+
+
+ General
+
+
+
+
+
+
+ Members
+
+
+
+ {IS_BILLING_ENABLED && (
+
+
+
+ Billing
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx
new file mode 100644
index 000000000..de01ca9bf
--- /dev/null
+++ b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import type { HTMLAttributes } from 'react';
+
+import Link from 'next/link';
+import { useParams, usePathname } from 'next/navigation';
+
+import { CreditCard, Key, User } from 'lucide-react';
+
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+
+export type MobileNavProps = HTMLAttributes;
+
+export const MobileNav = ({ className, ...props }: MobileNavProps) => {
+ const pathname = usePathname();
+ const params = useParams();
+
+ const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
+
+ const settingsPath = `/t/${teamUrl}/settings`;
+ const membersPath = `/t/${teamUrl}/settings/members`;
+ const billingPath = `/t/${teamUrl}/settings/billing`;
+
+ return (
+
+
+
+
+ General
+
+
+
+
+
+
+ Members
+
+
+
+ {IS_BILLING_ENABLED && (
+
+
+
+ Billing
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx
new file mode 100644
index 000000000..0dd4bcf4c
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import { trpc } from '@documenso/trpc/react';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { Button } from '@documenso/ui/primitives/button';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+import { LeaveTeamDialog } from '../dialogs/leave-team-dialog';
+
+export const CurrentUserTeamsDataTable = () => {
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
+ Object.fromEntries(searchParams ?? []),
+ );
+
+ const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeams.useQuery(
+ {
+ term: parsedSearchParams.query,
+ 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 (
+ (
+
+ {row.original.name}
+ }
+ secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
+ />
+
+ ),
+ },
+ {
+ header: 'Role',
+ accessorKey: 'role',
+ cell: ({ row }) =>
+ row.original.ownerUserId === row.original.currentTeamMember.userId
+ ? 'Owner'
+ : TEAM_MEMBER_ROLE_MAP[row.original.currentTeamMember.role],
+ },
+ {
+ header: 'Member Since',
+ accessorKey: 'createdAt',
+ cell: ({ row }) => ,
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => (
+
+ {canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && (
+
+ Manage
+
+ )}
+
+ e.preventDefault()}
+ >
+ Leave
+
+ }
+ />
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ onPaginationChange={onPaginationChange}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx
new file mode 100644
index 000000000..64a58375c
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx
@@ -0,0 +1,53 @@
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type PendingUserTeamsDataTableActionsProps = {
+ className?: string;
+ pendingTeamId: number;
+ onPayClick: (pendingTeamId: number) => void;
+};
+
+export const PendingUserTeamsDataTableActions = ({
+ className,
+ pendingTeamId,
+ onPayClick,
+}: PendingUserTeamsDataTableActionsProps) => {
+ const { toast } = useToast();
+
+ const { mutateAsync: deleteTeamPending, isLoading: deletingTeam } =
+ trpc.team.deleteTeamPending.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Pending team deleted.',
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ description:
+ 'We encountered an unknown error while attempting to delete the pending team. Please try again later.',
+ duration: 10000,
+ variant: 'destructive',
+ });
+ },
+ });
+
+ return (
+
+ onPayClick(pendingTeamId)}>
+ Pay
+
+
+ deleteTeamPending({ pendingTeamId: pendingTeamId })}
+ >
+ Remove
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx
new file mode 100644
index 000000000..84d4e38df
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx
@@ -0,0 +1,145 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useSearchParams } from 'next/navigation';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { trpc } from '@documenso/trpc/react';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+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';
+
+import { CreateTeamCheckoutDialog } from '../dialogs/create-team-checkout-dialog';
+import { PendingUserTeamsDataTableActions } from './pending-user-teams-data-table-actions';
+
+export const PendingUserTeamsDataTable = () => {
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
+ Object.fromEntries(searchParams ?? []),
+ );
+
+ const [checkoutPendingTeamId, setCheckoutPendingTeamId] = useState(null);
+
+ const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery(
+ {
+ term: parsedSearchParams.query,
+ 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,
+ };
+
+ useEffect(() => {
+ const searchParamCheckout = searchParams?.get('checkout');
+
+ if (searchParamCheckout && !isNaN(parseInt(searchParamCheckout))) {
+ setCheckoutPendingTeamId(parseInt(searchParamCheckout));
+ updateSearchParams({ checkout: null });
+ }
+ }, [searchParams, updateSearchParams]);
+
+ return (
+ <>
+ (
+ {row.original.name}
+ }
+ secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
+ />
+ ),
+ },
+ {
+ header: 'Created on',
+ accessorKey: 'createdAt',
+ cell: ({ row }) => ,
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => (
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ onPaginationChange={onPaginationChange}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+
+ setCheckoutPendingTeamId(null)}
+ />
+ >
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx
new file mode 100644
index 000000000..a860ac6d9
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx
@@ -0,0 +1,152 @@
+'use client';
+
+import Link from 'next/link';
+
+import { File } from 'lucide-react';
+import { DateTime } from 'luxon';
+
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+
+export type TeamBillingInvoicesDataTableProps = {
+ teamId: number;
+};
+
+export const TeamBillingInvoicesDataTable = ({ teamId }: TeamBillingInvoicesDataTableProps) => {
+ const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamInvoices.useQuery(
+ {
+ teamId,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const formatCurrency = (currency: string, amount: number) => {
+ const formatter = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ });
+
+ return formatter.format(amount);
+ };
+
+ const results = {
+ data: data?.data ?? [],
+ perPage: 100,
+ currentPage: 1,
+ totalPages: 1,
+ };
+
+ return (
+ (
+
+
+
+
+
+ {DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')}
+
+
+ {row.original.quantity} {row.original.quantity > 1 ? 'Seats' : 'Seat'}
+
+
+
+ ),
+ },
+ {
+ header: 'Status',
+ accessorKey: 'status',
+ cell: ({ row }) => {
+ const { status, paid } = row.original;
+
+ if (!status) {
+ return paid ? 'Paid' : 'Unpaid';
+ }
+
+ return status.charAt(0).toUpperCase() + status.slice(1);
+ },
+ },
+ {
+ header: 'Amount',
+ accessorKey: 'total',
+ cell: ({ row }) => formatCurrency(row.original.currency, row.original.total / 100),
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => (
+
+
+
+ View
+
+
+
+
+
+ Download
+
+
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx
new file mode 100644
index 000000000..f0e3580e3
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx
@@ -0,0 +1,203 @@
+'use client';
+
+import { useSearchParams } from 'next/navigation';
+
+import { History, MoreHorizontal, Trash2 } from 'lucide-react';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { trpc } from '@documenso/trpc/react';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+export type TeamMemberInvitesDataTableProps = {
+ teamId: number;
+};
+
+export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTableProps) => {
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const { toast } = useToast();
+
+ const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
+ Object.fromEntries(searchParams ?? []),
+ );
+
+ const { data, isLoading, isInitialLoading, isLoadingError } =
+ trpc.team.findTeamMemberInvites.useQuery(
+ {
+ teamId,
+ term: parsedSearchParams.query,
+ page: parsedSearchParams.page,
+ perPage: parsedSearchParams.perPage,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const { mutateAsync: resendTeamMemberInvitation } =
+ trpc.team.resendTeamMemberInvitation.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Invitation has been resent',
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ description: 'Unable to resend invitation. Please try again.',
+ variant: 'destructive',
+ });
+ },
+ });
+
+ const { mutateAsync: deleteTeamMemberInvitations } =
+ trpc.team.deleteTeamMemberInvitations.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Invitation has been deleted',
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ description: 'Unable to delete invitation. Please try again.',
+ variant: 'destructive',
+ });
+ },
+ });
+
+ const onPaginationChange = (page: number, perPage: number) => {
+ updateSearchParams({
+ page,
+ perPage,
+ });
+ };
+
+ const results = data ?? {
+ data: [],
+ perPage: 10,
+ currentPage: 1,
+ totalPages: 1,
+ };
+
+ return (
+ {
+ return (
+ {row.original.email}
+ }
+ />
+ );
+ },
+ },
+ {
+ header: 'Role',
+ accessorKey: 'role',
+ cell: ({ row }) => TEAM_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role,
+ },
+ {
+ header: 'Invited At',
+ accessorKey: 'createdAt',
+ cell: ({ row }) => ,
+ },
+ {
+ header: 'Actions',
+ cell: ({ row }) => (
+
+
+
+
+
+
+ Actions
+
+
+ resendTeamMemberInvitation({
+ teamId,
+ invitationId: row.original.id,
+ })
+ }
+ >
+
+ Resend
+
+
+
+ deleteTeamMemberInvitations({
+ teamId,
+ invitationIds: [row.original.id],
+ })
+ }
+ >
+
+ Remove
+
+
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ onPaginationChange={onPaginationChange}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/team-members-data-table.tsx b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx
new file mode 100644
index 000000000..3002ecbb0
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx
@@ -0,0 +1,209 @@
+'use client';
+
+import { useSearchParams } from 'next/navigation';
+
+import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
+import type { TeamMemberRole } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+import { DeleteTeamMemberDialog } from '../dialogs/delete-team-member-dialog';
+import { UpdateTeamMemberDialog } from '../dialogs/update-team-member-dialog';
+
+export type TeamMembersDataTableProps = {
+ currentUserTeamRole: TeamMemberRole;
+ teamOwnerUserId: number;
+ teamId: number;
+ teamName: string;
+};
+
+export const TeamMembersDataTable = ({
+ currentUserTeamRole,
+ teamOwnerUserId,
+ teamId,
+ teamName,
+}: TeamMembersDataTableProps) => {
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
+ Object.fromEntries(searchParams ?? []),
+ );
+
+ const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery(
+ {
+ teamId,
+ term: parsedSearchParams.query,
+ 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 (
+ {
+ const avatarFallbackText = row.original.user.name
+ ? extractInitials(row.original.user.name)
+ : row.original.user.email.slice(0, 1).toUpperCase();
+
+ return (
+ {row.original.user.name}
+ }
+ secondaryText={row.original.user.email}
+ />
+ );
+ },
+ },
+ {
+ header: 'Role',
+ accessorKey: 'role',
+ cell: ({ row }) =>
+ teamOwnerUserId === row.original.userId
+ ? 'Owner'
+ : TEAM_MEMBER_ROLE_MAP[row.original.role],
+ },
+ {
+ header: 'Member Since',
+ accessorKey: 'createdAt',
+ cell: ({ row }) => ,
+ },
+ {
+ header: 'Actions',
+ cell: ({ row }) => (
+
+
+
+
+
+
+ Actions
+
+ e.preventDefault()}
+ title="Update team member role"
+ >
+
+ Update role
+
+ }
+ />
+
+ e.preventDefault()}
+ disabled={
+ teamOwnerUserId === row.original.userId ||
+ !isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role)
+ }
+ title="Remove team member"
+ >
+
+ Remove
+
+ }
+ />
+
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ onPaginationChange={onPaginationChange}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx
new file mode 100644
index 000000000..316c4373f
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import Link from 'next/link';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
+import type { TeamMemberRole } from '@documenso/prisma/client';
+import { Input } from '@documenso/ui/primitives/input';
+import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
+
+import { TeamMemberInvitesDataTable } from '~/components/(teams)/tables/team-member-invites-data-table';
+import { TeamMembersDataTable } from '~/components/(teams)/tables/team-members-data-table';
+
+export type TeamsMemberPageDataTableProps = {
+ currentUserTeamRole: TeamMemberRole;
+ teamId: number;
+ teamName: string;
+ teamOwnerUserId: number;
+};
+
+export const TeamsMemberPageDataTable = ({
+ currentUserTeamRole,
+ teamId,
+ teamName,
+ teamOwnerUserId,
+}: TeamsMemberPageDataTableProps) => {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
+
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
+
+ const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
+
+ /**
+ * Handle debouncing the search query.
+ */
+ useEffect(() => {
+ if (!pathname) {
+ return;
+ }
+
+ const params = new URLSearchParams(searchParams?.toString());
+
+ params.set('query', debouncedSearchQuery);
+
+ if (debouncedSearchQuery === '') {
+ params.delete('query');
+ }
+
+ router.push(`${pathname}?${params.toString()}`);
+ }, [debouncedSearchQuery, pathname, router, searchParams]);
+
+ return (
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search"
+ />
+
+
+
+
+ All
+
+
+
+ Pending
+
+
+
+
+
+ {currentTab === 'invites' ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx b/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx
new file mode 100644
index 000000000..277421263
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import Link from 'next/link';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
+import { trpc } from '@documenso/trpc/react';
+import { Input } from '@documenso/ui/primitives/input';
+import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
+
+import { CurrentUserTeamsDataTable } from './current-user-teams-data-table';
+import { PendingUserTeamsDataTable } from './pending-user-teams-data-table';
+
+export const UserSettingsTeamsPageDataTable = () => {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
+
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
+
+ const currentTab = searchParams?.get('tab') === 'pending' ? 'pending' : 'active';
+
+ const { data } = trpc.team.findTeamsPending.useQuery(
+ {},
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ /**
+ * Handle debouncing the search query.
+ */
+ useEffect(() => {
+ if (!pathname) {
+ return;
+ }
+
+ const params = new URLSearchParams(searchParams?.toString());
+
+ params.set('query', debouncedSearchQuery);
+
+ if (debouncedSearchQuery === '') {
+ params.delete('query');
+ }
+
+ router.push(`${pathname}?${params.toString()}`);
+ }, [debouncedSearchQuery, pathname, router, searchParams]);
+
+ return (
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search"
+ />
+
+
+
+
+ Active
+
+
+
+
+ Pending
+ {data && data.count > 0 && (
+ {data.count}
+ )}
+
+
+
+
+
+
+ {currentTab === 'pending' ?
:
}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/team-billing-portal-button.tsx b/apps/web/src/components/(teams)/team-billing-portal-button.tsx
new file mode 100644
index 000000000..808b9b9ba
--- /dev/null
+++ b/apps/web/src/components/(teams)/team-billing-portal-button.tsx
@@ -0,0 +1,39 @@
+'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 TeamBillingPortalButtonProps = {
+ buttonProps?: React.ComponentProps;
+ teamId: number;
+};
+
+export const TeamBillingPortalButton = ({ buttonProps, teamId }: TeamBillingPortalButtonProps) => {
+ const { toast } = useToast();
+
+ const { mutateAsync: createBillingPortal, isLoading } =
+ trpc.team.createBillingPortal.useMutation();
+
+ const handleCreatePortal = async () => {
+ try {
+ const sessionUrl = await createBillingPortal({ teamId });
+
+ window.open(sessionUrl, '_blank');
+ } 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,
+ });
+ }
+ };
+
+ return (
+ handleCreatePortal()} loading={isLoading}>
+ Manage subscription
+
+ );
+};
diff --git a/apps/web/src/components/forms/2fa/authenticator-app.tsx b/apps/web/src/components/forms/2fa/authenticator-app.tsx
index 1d164bd22..316272e34 100644
--- a/apps/web/src/components/forms/2fa/authenticator-app.tsx
+++ b/apps/web/src/components/forms/2fa/authenticator-app.tsx
@@ -19,27 +19,14 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps)
return (
<>
-
-
-
Authenticator app
-
-
- Create one-time passwords that serve as a secondary authentication method for confirming
- your identity when requested during the sign-in process.
-
-
-
-
- {isTwoFactorEnabled ? (
- setModalState('disable')} size="sm">
- Disable 2FA
-
- ) : (
- setModalState('enable')} size="sm">
- Enable 2FA
-
- )}
-
+
+ {isTwoFactorEnabled ? (
+ setModalState('disable')}>
+ Disable 2FA
+
+ ) : (
+ setModalState('enable')}>Enable 2FA
+ )}
-
- onOpenChange(false)}>
+
+ onOpenChange(false)}>
Cancel
@@ -157,7 +158,7 @@ export const DisableAuthenticatorAppDialog = ({
>
Disable 2FA
-
+
diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
index 0db1c8b50..7a181c4cc 100644
--- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
+++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
@@ -15,6 +15,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
+ DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
@@ -190,15 +191,15 @@ export const EnableAuthenticatorAppDialog = ({
)}
/>
-
- onOpenChange(false)}>
+
+ onOpenChange(false)}>
Cancel
Continue
-
+
);
@@ -251,15 +252,15 @@ export const EnableAuthenticatorAppDialog = ({
)}
/>
-
- onOpenChange(false)}>
+
+ onOpenChange(false)}>
Cancel
Enable 2FA
-
+
))
diff --git a/apps/web/src/components/forms/2fa/recovery-codes.tsx b/apps/web/src/components/forms/2fa/recovery-codes.tsx
index 7e8950227..29834c74a 100644
--- a/apps/web/src/components/forms/2fa/recovery-codes.tsx
+++ b/apps/web/src/components/forms/2fa/recovery-codes.tsx
@@ -7,7 +7,6 @@ import { Button } from '@documenso/ui/primitives/button';
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
type RecoveryCodesProps = {
- // backupCodes: string[] | null;
isTwoFactorEnabled: boolean;
};
@@ -16,22 +15,13 @@ export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
return (
<>
-
-
-
Recovery Codes
-
-
- Recovery codes are used to access your account in the event that you lose access to your
- authenticator app.
-
-
-
-
- setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
- View Codes
-
-
-
+
setIsOpen(true)}
+ disabled={!isTwoFactorEnabled}
+ >
+ View Codes
+
-
- onOpenChange(false)}>
+
+ onOpenChange(false)}>
Cancel
Continue
-
+
);
diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx
index 0eb491537..03f95ff7f 100644
--- a/apps/web/src/components/forms/password.tsx
+++ b/apps/web/src/components/forms/password.tsx
@@ -7,6 +7,7 @@ import { z } from 'zod';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
+import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -22,18 +23,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPasswordFormSchema = z
.object({
- currentPassword: z
- .string()
- .min(6, { message: 'Password should contain at least 6 characters' })
- .max(72, { message: 'Password should not contain more than 72 characters' }),
- password: z
- .string()
- .min(6, { message: 'Password should contain at least 6 characters' })
- .max(72, { message: 'Password should not contain more than 72 characters' }),
- repeatedPassword: z
- .string()
- .min(6, { message: 'Password should contain at least 6 characters' })
- .max(72, { message: 'Password should not contain more than 72 characters' }),
+ currentPassword: ZCurrentPasswordSchema,
+ password: ZPasswordSchema,
+ repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
@@ -145,7 +137,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
/>
-
+
{isSubmitting ? 'Updating password...' : 'Update password'}
diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx
index 7036f4e43..2c278292f 100644
--- a/apps/web/src/components/forms/profile.tsx
+++ b/apps/web/src/components/forms/profile.tsx
@@ -121,10 +121,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
onChange(v ?? '')}
/>
diff --git a/apps/web/src/components/forms/reset-password.tsx b/apps/web/src/components/forms/reset-password.tsx
index 354584f6e..03608a27d 100644
--- a/apps/web/src/components/forms/reset-password.tsx
+++ b/apps/web/src/components/forms/reset-password.tsx
@@ -8,6 +8,7 @@ import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
+import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -23,8 +24,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZResetPasswordFormSchema = z
.object({
- password: z.string().min(6).max(72),
- repeatedPassword: z.string().min(6).max(72),
+ password: ZPasswordSchema,
+ repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
path: ['repeatedPassword'],
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx
index d0b5e1b60..ec690a568 100644
--- a/apps/web/src/components/forms/signin.tsx
+++ b/apps/web/src/components/forms/signin.tsx
@@ -11,9 +11,16 @@ import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod';
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
+import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
@@ -43,7 +50,7 @@ const LOGIN_REDIRECT_PATH = '/documents';
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),
- password: z.string().min(6).max(72),
+ password: ZCurrentPasswordSchema,
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
@@ -52,10 +59,11 @@ export type TSignInFormSchema = z.infer;
export type SignInFormProps = {
className?: string;
+ initialEmail?: string;
isGoogleSSOEnabled?: boolean;
};
-export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => {
+export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
const { toast } = useToast();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
@@ -67,7 +75,7 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
const form = useForm({
values: {
- email: '',
+ email: initialEmail ?? '',
password: '',
totpCode: '',
backupCode: '',
@@ -115,7 +123,6 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
const result = await signIn('credentials', {
...credentials,
-
callbackUrl: LOGIN_REDIRECT_PATH,
redirect: false,
});
@@ -285,21 +292,23 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
)}
/>
)}
+
+
+
+ {twoFactorAuthenticationMethod === 'totp'
+ ? 'Use Backup Code'
+ : 'Use Authenticator'}
+
+
+
+ {isSubmitting ? 'Signing in...' : 'Sign In'}
+
+
-
-
-
- {twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
-
-
-
- {isSubmitting ? 'Signing in...' : 'Sign In'}
-
-
diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx
index 7bfe07968..087e71fbe 100644
--- a/apps/web/src/components/forms/signup.tsx
+++ b/apps/web/src/components/forms/signup.tsx
@@ -10,6 +10,7 @@ import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
+import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -27,24 +28,32 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
const SIGN_UP_REDIRECT_PATH = '/documents';
-export const ZSignUpFormSchema = z.object({
- name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
- email: z.string().email().min(1),
- password: z
- .string()
- .min(6, { message: 'Password should contain at least 6 characters' })
- .max(72, { message: 'Password should not contain more than 72 characters' }),
- signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
-});
+export const ZSignUpFormSchema = z
+ .object({
+ name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
+ email: z.string().email().min(1),
+ password: ZPasswordSchema,
+ signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
+ })
+ .refine(
+ (data) => {
+ const { name, email, password } = data;
+ return !password.includes(name) && !password.includes(email.split('@')[0]);
+ },
+ {
+ message: 'Password should not be common or based on personal information',
+ },
+ );
export type TSignUpFormSchema = z.infer;
export type SignUpFormProps = {
className?: string;
+ initialEmail?: string;
isGoogleSSOEnabled?: boolean;
};
-export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => {
+export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
@@ -52,7 +61,7 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) =
const form = useForm({
values: {
name: '',
- email: '',
+ email: initialEmail ?? '',
password: '',
signature: '',
},
@@ -169,6 +178,7 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) =
onChange(v ?? '')}
/>
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index 25bfbbb40..46ee93fdf 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -1,14 +1,62 @@
-import { NextRequest, NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import type { NextRequest } from 'next/server';
+import { NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
+import { TEAM_URL_ROOT_REGEX } from '@documenso/lib/constants/teams';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+
export default async function middleware(req: NextRequest) {
+ const preferredTeamUrl = cookies().get('preferred-team-url');
+
+ const referrer = req.headers.get('referer');
+ const referrerUrl = referrer ? new URL(referrer) : null;
+ const referrerPathname = referrerUrl ? referrerUrl.pathname : null;
+
+ // Whether to reset the preferred team url cookie if the user accesses a non team page from a team page.
+ const resetPreferredTeamUrl =
+ referrerPathname &&
+ referrerPathname.startsWith('/t/') &&
+ (!req.nextUrl.pathname.startsWith('/t/') || req.nextUrl.pathname === '/');
+
+ // Redirect root page to `/documents` or `/t/{preferredTeamUrl}/documents`.
if (req.nextUrl.pathname === '/') {
- const redirectUrl = new URL('/documents', req.url);
+ const redirectUrlPath = formatDocumentsPath(
+ resetPreferredTeamUrl ? undefined : preferredTeamUrl?.value,
+ );
+
+ const redirectUrl = new URL(redirectUrlPath, req.url);
+ const response = NextResponse.redirect(redirectUrl);
+
+ return response;
+ }
+
+ // Redirect `/t` to `/settings/teams`.
+ if (req.nextUrl.pathname === '/t') {
+ const redirectUrl = new URL('/settings/teams', req.url);
return NextResponse.redirect(redirectUrl);
}
+ // Redirect `/t/` to `/t//documents`.
+ if (TEAM_URL_ROOT_REGEX.test(req.nextUrl.pathname)) {
+ const redirectUrl = new URL(`${req.nextUrl.pathname}/documents`, req.url);
+
+ const response = NextResponse.redirect(redirectUrl);
+ response.cookies.set('preferred-team-url', req.nextUrl.pathname.replace('/t/', ''));
+
+ return response;
+ }
+
+ // Set the preferred team url cookie if user accesses a team page.
+ if (req.nextUrl.pathname.startsWith('/t/')) {
+ const response = NextResponse.next();
+ response.cookies.set('preferred-team-url', req.nextUrl.pathname.split('/')[2]);
+
+ return response;
+ }
+
if (req.nextUrl.pathname.startsWith('/signin')) {
const token = await getToken({ req });
@@ -19,5 +67,34 @@ export default async function middleware(req: NextRequest) {
}
}
+ // Clear preferred team url cookie if user accesses a non team page from a team page.
+ if (resetPreferredTeamUrl || req.nextUrl.pathname === '/documents') {
+ const response = NextResponse.next();
+ response.cookies.set('preferred-team-url', '');
+
+ return response;
+ }
+
return NextResponse.next();
}
+
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - api (API routes)
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ * - ingest (analytics)
+ * - site.webmanifest
+ */
+ {
+ source: '/((?!api|_next/static|_next/image|ingest|favicon|site.webmanifest).*)',
+ missing: [
+ { type: 'header', key: 'next-router-prefetch' },
+ { type: 'header', key: 'purpose', value: 'prefetch' },
+ ],
+ },
+ ],
+};
diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts
index 4039703b8..365b6ec40 100644
--- a/apps/web/src/pages/api/auth/[...nextauth].ts
+++ b/apps/web/src/pages/api/auth/[...nextauth].ts
@@ -1,17 +1,65 @@
-// import { NextApiRequest, NextApiResponse } from 'next';
+import type { NextApiRequest, NextApiResponse } from 'next';
+
import NextAuth from 'next-auth';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
+import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
+import { prisma } from '@documenso/prisma';
+import { UserSecurityAuditLogType } from '@documenso/prisma/client';
-export default NextAuth({
- ...NEXT_AUTH_OPTIONS,
- pages: {
- signIn: '/signin',
- signOut: '/signout',
- error: '/signin',
- },
-});
+export default async function auth(req: NextApiRequest, res: NextApiResponse) {
+ const { ipAddress, userAgent } = extractNextApiRequestMetadata(req);
-// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
-// res.json({ hello: 'world' });
-// }
+ return await NextAuth(req, res, {
+ ...NEXT_AUTH_OPTIONS,
+ pages: {
+ signIn: '/signin',
+ signOut: '/signout',
+ error: '/signin',
+ },
+ events: {
+ signIn: async ({ user }) => {
+ await prisma.userSecurityAuditLog.create({
+ data: {
+ userId: user.id,
+ ipAddress,
+ userAgent,
+ type: UserSecurityAuditLogType.SIGN_IN,
+ },
+ });
+ },
+ signOut: async ({ token }) => {
+ const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;
+
+ if (isNaN(userId)) {
+ return;
+ }
+
+ await prisma.userSecurityAuditLog.create({
+ data: {
+ userId,
+ ipAddress,
+ userAgent,
+ type: UserSecurityAuditLogType.SIGN_OUT,
+ },
+ });
+ },
+ linkAccount: async ({ user }) => {
+ const userId = typeof user.id === 'string' ? parseInt(user.id) : user.id;
+
+ if (isNaN(userId)) {
+ return;
+ }
+
+ await prisma.userSecurityAuditLog.create({
+ data: {
+ userId,
+ ipAddress,
+ userAgent,
+ type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
+ },
+ });
+ },
+ },
+ });
+}
diff --git a/docker/Dockerfile b/docker/Dockerfile
index ecdd3b91b..4952b0bbd 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -39,6 +39,14 @@ ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
+# Encryption keys
+ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
+ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
+
+ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
+ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
+
+
# Uncomment and use build args to enable remote caching
# ARG TURBO_TEAM
# ENV TURBO_TEAM=$TURBO_TEAM
diff --git a/package-lock.json b/package-lock.json
index 69825e8d8..aae034c57 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -158,6 +158,7 @@
"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"
},
@@ -166,7 +167,8 @@
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
- "@types/react-dom": "18.2.7"
+ "@types/react-dom": "18.2.7",
+ "@types/ua-parser-js": "^0.7.39"
}
},
"apps/web/node_modules/@types/node": {
@@ -4884,9 +4886,9 @@
}
},
"node_modules/@radix-ui/react-select": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-1.2.2.tgz",
- "integrity": "sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==",
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
+ "integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/number": "1.0.1",
@@ -4895,12 +4897,12 @@
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-direction": "1.0.1",
- "@radix-ui/react-dismissable-layer": "1.0.4",
+ "@radix-ui/react-dismissable-layer": "1.0.5",
"@radix-ui/react-focus-guards": "1.0.1",
- "@radix-ui/react-focus-scope": "1.0.3",
+ "@radix-ui/react-focus-scope": "1.0.4",
"@radix-ui/react-id": "1.0.1",
- "@radix-ui/react-popper": "1.1.2",
- "@radix-ui/react-portal": "1.0.3",
+ "@radix-ui/react-popper": "1.1.3",
+ "@radix-ui/react-portal": "1.0.4",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-slot": "1.0.2",
"@radix-ui/react-use-callback-ref": "1.0.1",
@@ -4926,113 +4928,6 @@
}
}
},
- "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz",
- "integrity": "sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==",
- "dependencies": {
- "@babel/runtime": "^7.13.10",
- "@radix-ui/primitive": "1.0.1",
- "@radix-ui/react-compose-refs": "1.0.1",
- "@radix-ui/react-primitive": "1.0.3",
- "@radix-ui/react-use-callback-ref": "1.0.1",
- "@radix-ui/react-use-escape-keydown": "1.0.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0",
- "react-dom": "^16.8 || ^17.0 || ^18.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-scope": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.3.tgz",
- "integrity": "sha512-upXdPfqI4islj2CslyfUBNlaJCPybbqRHAi1KER7Isel9Q2AtSJ0zRBZv8mWQiFXD2nyAJ4BhC3yXgZ6kMBSrQ==",
- "dependencies": {
- "@babel/runtime": "^7.13.10",
- "@radix-ui/react-compose-refs": "1.0.1",
- "@radix-ui/react-primitive": "1.0.3",
- "@radix-ui/react-use-callback-ref": "1.0.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0",
- "react-dom": "^16.8 || ^17.0 || ^18.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.2.tgz",
- "integrity": "sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==",
- "dependencies": {
- "@babel/runtime": "^7.13.10",
- "@floating-ui/react-dom": "^2.0.0",
- "@radix-ui/react-arrow": "1.0.3",
- "@radix-ui/react-compose-refs": "1.0.1",
- "@radix-ui/react-context": "1.0.1",
- "@radix-ui/react-primitive": "1.0.3",
- "@radix-ui/react-use-callback-ref": "1.0.1",
- "@radix-ui/react-use-layout-effect": "1.0.1",
- "@radix-ui/react-use-rect": "1.0.1",
- "@radix-ui/react-use-size": "1.0.1",
- "@radix-ui/rect": "1.0.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0",
- "react-dom": "^16.8 || ^17.0 || ^18.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.3.tgz",
- "integrity": "sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==",
- "dependencies": {
- "@babel/runtime": "^7.13.10",
- "@radix-ui/react-primitive": "1.0.3"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0",
- "react-dom": "^16.8 || ^17.0 || ^18.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-separator": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz",
@@ -6756,6 +6651,12 @@
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A=="
},
+ "node_modules/@types/ua-parser-js": {
+ "version": "0.7.39",
+ "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz",
+ "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==",
+ "dev": true
+ },
"node_modules/@types/unist": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
@@ -18643,6 +18544,28 @@
"node": ">=14.17"
}
},
+ "node_modules/ua-parser-js": {
+ "version": "1.0.37",
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz",
+ "integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ua-parser-js"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/faisalman"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/faisalman"
+ }
+ ],
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@@ -19720,13 +19643,19 @@
"@prisma/client": "5.4.2",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
- "prisma": "5.4.2"
+ "prisma": "5.4.2",
+ "ts-pattern": "^5.0.6"
},
"devDependencies": {
"ts-node": "^10.9.1",
"typescript": "5.2.2"
}
},
+ "packages/prisma/node_modules/ts-pattern": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.6.tgz",
+ "integrity": "sha512-Y+jOjihlFriWzcBjncPCf2/am+Hgz7LtsWs77pWg5vQQKLQj07oNrJryo/wK2G0ndNaoVn2ownFMeoeAuReu3Q=="
+ },
"packages/prisma/node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@@ -19834,7 +19763,7 @@
"@radix-ui/react-checkbox": "^1.0.3",
"@radix-ui/react-collapsible": "^1.0.2",
"@radix-ui/react-context-menu": "^2.1.3",
- "@radix-ui/react-dialog": "^1.0.3",
+ "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-hover-card": "^1.0.5",
"@radix-ui/react-label": "^2.0.1",
@@ -19844,7 +19773,7 @@
"@radix-ui/react-progress": "^1.0.2",
"@radix-ui/react-radio-group": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.0.3",
- "@radix-ui/react-select": "^1.2.1",
+ "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.2",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts
new file mode 100644
index 000000000..f1926fb2a
--- /dev/null
+++ b/packages/app-tests/e2e/fixtures/authentication.ts
@@ -0,0 +1,40 @@
+import type { Page } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+
+type ManualLoginOptions = {
+ page: Page;
+ email?: string;
+ password?: string;
+
+ /**
+ * Where to navigate after login.
+ */
+ redirectPath?: string;
+};
+
+export const manualLogin = async ({
+ page,
+ email = 'example@documenso.com',
+ password = 'password',
+ redirectPath,
+}: ManualLoginOptions) => {
+ await page.goto(`${WEBAPP_BASE_URL}/signin`);
+
+ await page.getByLabel('Email').click();
+ await page.getByLabel('Email').fill(email);
+
+ await page.getByLabel('Password', { exact: true }).fill(password);
+ await page.getByLabel('Password', { exact: true }).press('Enter');
+
+ if (redirectPath) {
+ await page.waitForURL(`${WEBAPP_BASE_URL}/documents`);
+ await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
+ }
+};
+
+export const manualSignout = async ({ page }: ManualLoginOptions) => {
+ await page.getByTestId('menu-switcher').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+ await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
+};
diff --git a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
index 12a099bbf..da95c66f0 100644
--- a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
+++ b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
@@ -2,6 +2,8 @@ import { expect, test } from '@playwright/test';
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
+import { manualLogin, manualSignout } from './fixtures/authentication';
+
test.describe.configure({ mode: 'serial' });
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
@@ -19,17 +21,11 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
for (const recipient of recipients) {
- await page.goto('/signin');
-
- await page.getByLabel('Email').fill(recipient.email);
- await page.getByLabel('Password', { exact: true }).fill(recipient.password);
- await page.getByRole('button', { name: 'Sign In' }).click();
+ await page.waitForURL('/signin');
+ await manualLogin({ page, email: recipient.email, password: recipient.password });
await page.waitForURL('/documents');
@@ -38,10 +34,7 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
}
});
@@ -74,13 +67,10 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
- // signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
for (const recipient of recipients) {
+ await page.waitForURL('/signin');
await page.goto('/signin');
// sign in
@@ -96,11 +86,7 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
await page.goto('/documents');
-
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
}
});
@@ -115,11 +101,7 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
await page.goto('/signin');
- // sign in
- await page.getByLabel('Email').fill(sender.email);
- await page.getByLabel('Password', { exact: true }).fill(sender.password);
- await page.getByRole('button', { name: 'Sign In' }).click();
-
+ await manualLogin({ page, email: sender.email, password: sender.password });
await page.waitForURL('/documents');
// open actions menu
@@ -133,19 +115,12 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
for (const recipient of recipients) {
- await page.goto('/signin');
-
- // sign in
- await page.getByLabel('Email').fill(recipient.email);
- await page.getByLabel('Password', { exact: true }).fill(recipient.password);
- await page.getByRole('button', { name: 'Sign In' }).click();
+ await page.waitForURL('/signin');
+ await manualLogin({ page, email: recipient.email, password: recipient.password });
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
@@ -154,11 +129,9 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
+ await page.waitForURL('/documents');
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
}
});
@@ -167,13 +140,7 @@ test('[PR-711]: deleting a draft document should remove it without additional pr
}) => {
const [sender] = TEST_USERS;
- await page.goto('/signin');
-
- // sign in
- await page.getByLabel('Email').fill(sender.email);
- await page.getByLabel('Password', { exact: true }).fill(sender.password);
- await page.getByRole('button', { name: 'Sign In' }).click();
-
+ await manualLogin({ page, email: sender.email, password: sender.password });
await page.waitForURL('/documents');
// open actions menu
diff --git a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts
index e9ae60d0e..160113f95 100644
--- a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts
+++ b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts
@@ -17,12 +17,6 @@ test('[PR-713]: should see sent documents', async ({ page }) => {
await page.getByPlaceholder('Type a command or search...').fill('sent');
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
-
- await page.keyboard.press('Escape');
-
- // signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
});
test('[PR-713]: should see received documents', async ({ page }) => {
@@ -40,12 +34,6 @@ test('[PR-713]: should see received documents', async ({ page }) => {
await page.getByPlaceholder('Type a command or search...').fill('received');
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
-
- await page.keyboard.press('Escape');
-
- // signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
});
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
@@ -63,10 +51,4 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => {
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
-
- await page.keyboard.press('Escape');
-
- // signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
});
diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts
new file mode 100644
index 000000000..aed56b2bc
--- /dev/null
+++ b/packages/app-tests/e2e/teams/manage-team.spec.ts
@@ -0,0 +1,87 @@
+import { test } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
+import { seedUser } from '@documenso/prisma/seed/users';
+
+import { manualLogin } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+test('[TEAMS]: create team', async ({ page }) => {
+ const user = await seedUser();
+
+ await manualLogin({
+ page,
+ email: user.email,
+ redirectPath: '/settings/teams',
+ });
+
+ const teamId = `team-${Date.now()}`;
+
+ // Create team.
+ await page.getByRole('button', { name: 'Create team' }).click();
+ await page.getByLabel('Team Name*').fill(teamId);
+ await page.getByTestId('dialog-create-team-button').click();
+
+ await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' });
+
+ const isCheckoutRequired = page.url().includes('pending');
+ test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.');
+
+ // Goto new team settings page.
+ await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click();
+
+ await unseedTeam(teamId);
+});
+
+test('[TEAMS]: delete team', async ({ page }) => {
+ const team = await seedTeam();
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ redirectPath: `/t/${team.url}/settings`,
+ });
+
+ // Delete team.
+ await page.getByRole('button', { name: 'Delete team' }).click();
+ await page.getByLabel(`Confirm by typing delete ${team.url}`).fill(`delete ${team.url}`);
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ // Check that we have been redirected to the teams page.
+ await page.waitForURL(`${WEBAPP_BASE_URL}/settings/teams`);
+});
+
+test('[TEAMS]: update team', async ({ page }) => {
+ const team = await seedTeam();
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ });
+
+ // Navigate to create team page.
+ await page.getByTestId('menu-switcher').click();
+ await page.getByRole('menuitem', { name: 'Manage teams' }).click();
+
+ // Goto team settings page.
+ await page.getByRole('row').filter({ hasText: team.url }).getByRole('link').nth(1).click();
+
+ const updatedTeamId = `team-${Date.now()}`;
+
+ // Update team.
+ await page.getByLabel('Team Name*').click();
+ await page.getByLabel('Team Name*').clear();
+ await page.getByLabel('Team Name*').fill(updatedTeamId);
+ await page.getByLabel('Team URL*').click();
+ await page.getByLabel('Team URL*').clear();
+ await page.getByLabel('Team URL*').fill(updatedTeamId);
+
+ await page.getByRole('button', { name: 'Update team' }).click();
+
+ // Check we have been redirected to the new team URL and the name is updated.
+ await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`);
+
+ await unseedTeam(updatedTeamId);
+});
diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts
new file mode 100644
index 000000000..210189ca7
--- /dev/null
+++ b/packages/app-tests/e2e/teams/team-documents.spec.ts
@@ -0,0 +1,282 @@
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import { DocumentStatus } from '@documenso/prisma/client';
+import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
+import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
+import { seedUser } from '@documenso/prisma/seed/users';
+
+import { manualLogin, manualSignout } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
+ await page.getByRole('tab', { name: tabName }).click();
+
+ if (tabName !== 'All') {
+ await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
+ }
+
+ if (count === 0) {
+ await expect(page.getByRole('main')).toContainText(`Nothing to do`);
+ return;
+ }
+
+ await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
+};
+
+test('[TEAMS]: check team documents count', async ({ page }) => {
+ const { team, teamMember2 } = await seedTeamDocuments();
+
+ // Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
+ for (const user of [team.owner, teamMember2]) {
+ await manualLogin({
+ page,
+ email: user.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ // Check document counts.
+ await checkDocumentTabCount(page, 'Inbox', 0);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 1);
+ await checkDocumentTabCount(page, 'Draft', 2);
+ await checkDocumentTabCount(page, 'All', 5);
+
+ // Apply filter.
+ await page.locator('button').filter({ hasText: 'Sender: All' }).click();
+ await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
+ await page.waitForURL(/senderIds/);
+
+ // Check counts after filtering.
+ await checkDocumentTabCount(page, 'Inbox', 0);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 0);
+ await checkDocumentTabCount(page, 'Draft', 1);
+ await checkDocumentTabCount(page, 'All', 3);
+
+ await manualSignout({ page });
+ }
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
+ const { team, teamMember2, teamMember4 } = await seedTeamDocuments();
+ const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
+
+ const teamEmailMember = teamMember4;
+
+ await seedTeamEmail({
+ email: teamEmailMember.email,
+ teamId: team.id,
+ });
+
+ const testUser1 = await seedUser();
+
+ await seedDocuments([
+ // Documents sent from the team email account.
+ {
+ sender: teamEmailMember,
+ recipients: [testUser1],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ },
+ },
+ {
+ sender: teamEmailMember,
+ recipients: [testUser1],
+ type: DocumentStatus.PENDING,
+ documentOptions: {
+ teamId: team.id,
+ },
+ },
+ {
+ sender: teamMember4,
+ recipients: [testUser1],
+ type: DocumentStatus.DRAFT,
+ },
+ // Documents sent to the team email account.
+ {
+ sender: testUser1,
+ recipients: [teamEmailMember],
+ type: DocumentStatus.COMPLETED,
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmailMember],
+ type: DocumentStatus.PENDING,
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmailMember],
+ type: DocumentStatus.DRAFT,
+ },
+ // Document sent to the team email account from another team.
+ {
+ sender: team2Member2,
+ recipients: [teamEmailMember],
+ type: DocumentStatus.PENDING,
+ documentOptions: {
+ teamId: team2.id,
+ },
+ },
+ ]);
+
+ // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
+ for (const user of [team.owner, teamEmailMember]) {
+ await manualLogin({
+ page,
+ email: user.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ // Check document counts.
+ await checkDocumentTabCount(page, 'Inbox', 2);
+ await checkDocumentTabCount(page, 'Pending', 3);
+ await checkDocumentTabCount(page, 'Completed', 3);
+ await checkDocumentTabCount(page, 'Draft', 3);
+ await checkDocumentTabCount(page, 'All', 11);
+
+ // Apply filter.
+ await page.locator('button').filter({ hasText: 'Sender: All' }).click();
+ await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
+ await page.waitForURL(/senderIds/);
+
+ // Check counts after filtering.
+ await checkDocumentTabCount(page, 'Inbox', 0);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 0);
+ await checkDocumentTabCount(page, 'Draft', 1);
+ await checkDocumentTabCount(page, 'All', 3);
+
+ await manualSignout({ page });
+ }
+
+ await unseedTeamEmail({ teamId: team.id });
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: check team documents count with external team email', async ({ page }) => {
+ const { team, teamMember2 } = await seedTeamDocuments();
+ const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
+
+ const teamEmail = `external-team-email-${team.id}@test.documenso.com`;
+
+ await seedTeamEmail({
+ email: teamEmail,
+ teamId: team.id,
+ });
+
+ const testUser1 = await seedUser();
+
+ await seedDocuments([
+ // Documents sent to the team email account.
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.COMPLETED,
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.PENDING,
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.DRAFT,
+ },
+ // Document sent to the team email account from another team.
+ {
+ sender: team2Member2,
+ recipients: [teamEmail],
+ type: DocumentStatus.PENDING,
+ documentOptions: {
+ teamId: team2.id,
+ },
+ },
+ // Document sent to the team email account from an individual user.
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.PENDING,
+ documentOptions: {
+ teamId: team2.id,
+ },
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.DRAFT,
+ documentOptions: {
+ teamId: team2.id,
+ },
+ },
+ ]);
+
+ await manualLogin({
+ page,
+ email: teamMember2.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ // Check document counts.
+ await checkDocumentTabCount(page, 'Inbox', 3);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 2);
+ await checkDocumentTabCount(page, 'Draft', 2);
+ await checkDocumentTabCount(page, 'All', 9);
+
+ // Apply filter.
+ await page.locator('button').filter({ hasText: 'Sender: All' }).click();
+ await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
+ await page.waitForURL(/senderIds/);
+
+ // Check counts after filtering.
+ await checkDocumentTabCount(page, 'Inbox', 0);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 0);
+ await checkDocumentTabCount(page, 'Draft', 1);
+ await checkDocumentTabCount(page, 'All', 3);
+
+ await unseedTeamEmail({ teamId: team.id });
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: delete pending team document', async ({ page }) => {
+ const { team, teamMember2: currentUser } = await seedTeamDocuments();
+
+ await manualLogin({
+ page,
+ email: currentUser.email,
+ redirectPath: `/t/${team.url}/documents?status=PENDING`,
+ });
+
+ await page.getByRole('row').getByRole('button').nth(1).click();
+
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ await checkDocumentTabCount(page, 'Pending', 1);
+});
+
+test('[TEAMS]: resend pending team document', async ({ page }) => {
+ const { team, teamMember2: currentUser } = await seedTeamDocuments();
+
+ await manualLogin({
+ page,
+ email: currentUser.email,
+ redirectPath: `/t/${team.url}/documents?status=PENDING`,
+ });
+
+ await page.getByRole('row').getByRole('button').nth(1).click();
+ await page.getByRole('menuitem', { name: 'Resend' }).click();
+
+ await page.getByLabel('test.documenso.com').first().click();
+ await page.getByRole('button', { name: 'Send reminder' }).click();
+
+ await expect(page.getByRole('status')).toContainText('Document re-sent');
+});
diff --git a/packages/app-tests/e2e/teams/team-email.spec.ts b/packages/app-tests/e2e/teams/team-email.spec.ts
new file mode 100644
index 000000000..953be5aaf
--- /dev/null
+++ b/packages/app-tests/e2e/teams/team-email.spec.ts
@@ -0,0 +1,102 @@
+import { expect, test } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
+import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
+
+import { manualLogin } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+test('[TEAMS]: send team email request', async ({ page }) => {
+ const team = await seedTeam();
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ password: 'password',
+ redirectPath: `/t/${team.url}/settings`,
+ });
+
+ await page.getByRole('button', { name: 'Add email' }).click();
+ await page.getByPlaceholder('eg. Legal').click();
+ await page.getByPlaceholder('eg. Legal').fill('test@test.documenso.com');
+ await page.getByPlaceholder('example@example.com').click();
+ await page.getByPlaceholder('example@example.com').fill('test@test.documenso.com');
+ await page.getByRole('button', { name: 'Add' }).click();
+
+ await expect(
+ page
+ .getByRole('status')
+ .filter({ hasText: 'We have sent a confirmation email for verification.' })
+ .first(),
+ ).toBeVisible();
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: accept team email request', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const teamEmailVerification = await seedTeamEmailVerification({
+ email: 'team-email-verification@test.documenso.com',
+ teamId: team.id,
+ });
+
+ await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`);
+ await expect(page.getByRole('heading')).toContainText('Team email verified!');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: delete team email', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ createTeamEmail: true,
+ });
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ redirectPath: `/t/${team.url}/settings`,
+ });
+
+ await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
+
+ await page.getByRole('menuitem', { name: 'Remove' }).click();
+
+ await expect(page.getByText('Team email has been removed').first()).toBeVisible();
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: team email owner removes access', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ createTeamEmail: true,
+ });
+
+ if (!team.teamEmail) {
+ throw new Error('Not possible');
+ }
+
+ const teamEmailOwner = await seedUser({
+ email: team.teamEmail.email,
+ });
+
+ await manualLogin({
+ page,
+ email: teamEmailOwner.email,
+ redirectPath: `/settings/teams`,
+ });
+
+ await page.getByRole('button', { name: 'Revoke access' }).click();
+ await page.getByRole('button', { name: 'Revoke' }).click();
+
+ await expect(page.getByText('You have successfully revoked').first()).toBeVisible();
+
+ await unseedTeam(team.url);
+ await unseedUser(teamEmailOwner.id);
+});
diff --git a/packages/app-tests/e2e/teams/team-members.spec.ts b/packages/app-tests/e2e/teams/team-members.spec.ts
new file mode 100644
index 000000000..05f096c09
--- /dev/null
+++ b/packages/app-tests/e2e/teams/team-members.spec.ts
@@ -0,0 +1,110 @@
+import { expect, test } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
+import { seedUser } from '@documenso/prisma/seed/users';
+
+import { manualLogin } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+test('[TEAMS]: update team member role', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ password: 'password',
+ redirectPath: `/t/${team.url}/settings/members`,
+ });
+
+ const teamMemberToUpdate = team.members[1];
+
+ await page
+ .getByRole('row')
+ .filter({ hasText: teamMemberToUpdate.user.email })
+ .getByRole('button')
+ .click();
+
+ await page.getByRole('menuitem', { name: 'Update role' }).click();
+ await page.getByRole('combobox').click();
+ await page.getByLabel('Manager').click();
+ await page.getByRole('button', { name: 'Update' }).click();
+ await expect(
+ page.getByRole('row').filter({ hasText: teamMemberToUpdate.user.email }),
+ ).toContainText('Manager');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: accept team invitation without account', async ({ page }) => {
+ const team = await seedTeam();
+
+ const teamInvite = await seedTeamInvite({
+ email: `team-invite-test-${Date.now()}@test.documenso.com`,
+ teamId: team.id,
+ });
+
+ await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
+ await expect(page.getByRole('heading')).toContainText('Team invitation');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: accept team invitation with account', async ({ page }) => {
+ const team = await seedTeam();
+ const user = await seedUser();
+
+ const teamInvite = await seedTeamInvite({
+ email: user.email,
+ teamId: team.id,
+ });
+
+ await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
+ await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: member can leave team', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const teamMember = team.members[1];
+
+ await manualLogin({
+ page,
+ email: teamMember.user.email,
+ password: 'password',
+ redirectPath: `/settings/teams`,
+ });
+
+ await page.getByRole('button', { name: 'Leave' }).click();
+ await page.getByRole('button', { name: 'Leave' }).click();
+
+ await expect(page.getByRole('status').first()).toContainText(
+ 'You have successfully left this team.',
+ );
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: owner cannot leave team', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ password: 'password',
+ redirectPath: `/settings/teams`,
+ });
+
+ await expect(page.getByRole('button').getByText('Leave')).toBeDisabled();
+
+ await unseedTeam(team.url);
+});
diff --git a/packages/app-tests/e2e/teams/transfer-team.spec.ts b/packages/app-tests/e2e/teams/transfer-team.spec.ts
new file mode 100644
index 000000000..a5d95b720
--- /dev/null
+++ b/packages/app-tests/e2e/teams/transfer-team.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
+
+import { manualLogin } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const teamMember = team.members[1];
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ password: 'password',
+ redirectPath: `/t/${team.url}/settings`,
+ });
+
+ await page.getByRole('button', { name: 'Transfer team' }).click();
+
+ await page.getByRole('combobox').click();
+ await page.getByLabel(teamMember.user.name ?? '').click();
+ await page.getByLabel('Confirm by typing transfer').click();
+ await page.getByLabel('Confirm by typing transfer').fill('transfer');
+ await page.getByRole('button', { name: 'Transfer' }).click();
+
+ await expect(page.locator('[id="\\:r2\\:-form-item-message"]')).toContainText(
+ `You must enter 'transfer ${team.name}' to proceed`,
+ );
+
+ await page.getByLabel('Confirm by typing transfer').click();
+ await page.getByLabel('Confirm by typing transfer').fill(`transfer ${team.name}`);
+ await page.getByRole('button', { name: 'Transfer' }).click();
+
+ await expect(page.getByRole('heading', { name: 'Team transfer in progress' })).toBeVisible();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ await expect(page.getByRole('status').first()).toContainText(
+ 'The team transfer invitation has been successfully deleted.',
+ );
+
+ await unseedTeam(team.url);
+});
+
+/**
+ * Current skipped until we disable billing during tests.
+ */
+test.skip('[TEAMS]: accept team transfer', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const newOwnerMember = team.members[1];
+
+ const teamTransferRequest = await seedTeamTransfer({
+ teamId: team.id,
+ newOwnerUserId: newOwnerMember.userId,
+ });
+
+ await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`);
+ await expect(page.getByRole('heading')).toContainText('Team ownership transferred!');
+
+ await unseedTeam(team.url);
+});
diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts
index 9a07ec3c7..40ee5e768 100644
--- a/packages/app-tests/e2e/test-auth-flow.spec.ts
+++ b/packages/app-tests/e2e/test-auth-flow.spec.ts
@@ -12,7 +12,7 @@ test.describe.configure({ mode: 'serial' });
const username = 'Test User';
const email = 'test-user@auth-flow.documenso.com';
-const password = 'Password123';
+const password = 'Password123#';
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
await page.goto('/signup');
@@ -30,7 +30,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
await page.mouse.up();
}
- await page.getByRole('button', { name: 'Sign Up' }).click();
+ await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
diff --git a/packages/ee/server-only/limits/client.ts b/packages/ee/server-only/limits/client.ts
index 7f48e6856..9a36928b1 100644
--- a/packages/ee/server-only/limits/client.ts
+++ b/packages/ee/server-only/limits/client.ts
@@ -1,17 +1,23 @@
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants';
-import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
+import type { TLimitsResponseSchema } from './schema';
+import { ZLimitsResponseSchema } from './schema';
export type GetLimitsOptions = {
headers?: Record;
+ teamId?: number | null;
};
-export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
+export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`);
+ if (teamId) {
+ requestHeaders['team-id'] = teamId.toString();
+ }
+
return fetch(url, {
headers: {
...requestHeaders,
diff --git a/packages/ee/server-only/limits/constants.ts b/packages/ee/server-only/limits/constants.ts
index 71ff29d9d..4c428f34f 100644
--- a/packages/ee/server-only/limits/constants.ts
+++ b/packages/ee/server-only/limits/constants.ts
@@ -1,10 +1,15 @@
-import { TLimitsSchema } from './schema';
+import type { TLimitsSchema } from './schema';
export const FREE_PLAN_LIMITS: TLimitsSchema = {
documents: 5,
recipients: 10,
};
+export const TEAM_PLAN_LIMITS: TLimitsSchema = {
+ documents: Infinity,
+ recipients: Infinity,
+};
+
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
diff --git a/packages/ee/server-only/limits/handler.ts b/packages/ee/server-only/limits/handler.ts
index 69f77db75..a497b2314 100644
--- a/packages/ee/server-only/limits/handler.ts
+++ b/packages/ee/server-only/limits/handler.ts
@@ -1,10 +1,10 @@
-import { NextApiRequest, NextApiResponse } from 'next';
+import type { NextApiRequest, NextApiResponse } from 'next';
import { getToken } from 'next-auth/jwt';
import { match } from 'ts-pattern';
import { ERROR_CODES } from './errors';
-import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
+import type { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
import { getServerLimits } from './server';
export const limitsHandler = async (
@@ -14,7 +14,19 @@ export const limitsHandler = async (
try {
const token = await getToken({ req });
- const limits = await getServerLimits({ email: token?.email });
+ const rawTeamId = req.headers['team-id'];
+
+ let teamId: number | null = null;
+
+ if (typeof rawTeamId === 'string' && !isNaN(parseInt(rawTeamId, 10))) {
+ teamId = parseInt(rawTeamId, 10);
+ }
+
+ if (!teamId && rawTeamId) {
+ throw new Error(ERROR_CODES.INVALID_TEAM_ID);
+ }
+
+ const limits = await getServerLimits({ email: token?.email, teamId });
return res.status(200).json(limits);
} catch (err) {
diff --git a/packages/ee/server-only/limits/provider/client.tsx b/packages/ee/server-only/limits/provider/client.tsx
index 07a085750..fdc00b439 100644
--- a/packages/ee/server-only/limits/provider/client.tsx
+++ b/packages/ee/server-only/limits/provider/client.tsx
@@ -6,7 +6,7 @@ import { equals } from 'remeda';
import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
-import { TLimitsResponseSchema } from '../schema';
+import type { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema;
@@ -24,19 +24,22 @@ export const useLimits = () => {
export type LimitsProviderProps = {
initialValue?: LimitsContextValue;
+ teamId?: number;
children?: React.ReactNode;
};
-export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
- const defaultValue: TLimitsResponseSchema = {
+export const LimitsProvider = ({
+ initialValue = {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
- };
-
- const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
+ },
+ teamId,
+ children,
+}: LimitsProviderProps) => {
+ const [limits, setLimits] = useState(() => initialValue);
const refreshLimits = async () => {
- const newLimits = await getLimits();
+ const newLimits = await getLimits({ teamId });
setLimits((oldLimits) => {
if (equals(oldLimits, newLimits)) {
diff --git a/packages/ee/server-only/limits/provider/server.tsx b/packages/ee/server-only/limits/provider/server.tsx
index c9295483a..b7cde3573 100644
--- a/packages/ee/server-only/limits/provider/server.tsx
+++ b/packages/ee/server-only/limits/provider/server.tsx
@@ -3,16 +3,22 @@
import { headers } from 'next/headers';
import { getLimits } from '../client';
+import type { LimitsContextValue } from './client';
import { LimitsProvider as ClientLimitsProvider } from './client';
export type LimitsProviderProps = {
children?: React.ReactNode;
+ teamId?: number;
};
-export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
+export const LimitsProvider = async ({ children, teamId }: LimitsProviderProps) => {
const requestHeaders = Object.fromEntries(headers().entries());
- const limits = await getLimits({ headers: requestHeaders });
+ const limits: LimitsContextValue = await getLimits({ headers: requestHeaders, teamId });
- return {children} ;
+ return (
+
+ {children}
+
+ );
};
diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts
index f256c6356..e48eb7187 100644
--- a/packages/ee/server-only/limits/server.ts
+++ b/packages/ee/server-only/limits/server.ts
@@ -1,22 +1,22 @@
import { DateTime } from 'luxon';
-import { getFlag } from '@documenso/lib/universal/get-feature-flag';
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
-import { getPricesByType } from '../stripe/get-prices-by-type';
-import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
+import { getPricesByPlan } from '../stripe/get-prices-by-plan';
+import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
email?: string | null;
+ teamId?: number | null;
};
-export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
- const isBillingEnabled = await getFlag('app_billing');
-
- if (!isBillingEnabled) {
+export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
+ if (!IS_BILLING_ENABLED) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
@@ -27,6 +27,14 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
+ return teamId ? handleTeamLimits({ email, teamId }) : handleUserLimits({ email });
+};
+
+type HandleUserLimitsOptions = {
+ email: string;
+};
+
+const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
const user = await prisma.user.findFirst({
where: {
email,
@@ -48,10 +56,10 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
);
if (activeSubscriptions.length > 0) {
- const individualPrices = await getPricesByType('individual');
+ const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
for (const subscription of activeSubscriptions) {
- const price = individualPrices.find((price) => price.id === subscription.priceId);
+ const price = communityPlanPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}
@@ -71,6 +79,7 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
const documents = await prisma.document.count({
where: {
userId: user.id,
+ teamId: null,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
@@ -84,3 +93,50 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
remaining,
};
};
+
+type HandleTeamLimitsOptions = {
+ email: string;
+ teamId: number;
+};
+
+const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => {
+ const team = await prisma.team.findFirst({
+ where: {
+ id: teamId,
+ members: {
+ some: {
+ user: {
+ email,
+ },
+ },
+ },
+ },
+ include: {
+ subscription: true,
+ },
+ });
+
+ if (!team) {
+ throw new Error('Team not found');
+ }
+
+ const { subscription } = team;
+
+ if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
+ return {
+ quota: {
+ documents: 0,
+ recipients: 0,
+ },
+ remaining: {
+ documents: 0,
+ recipients: 0,
+ },
+ };
+ }
+
+ return {
+ quota: structuredClone(TEAM_PLAN_LIMITS),
+ remaining: structuredClone(TEAM_PLAN_LIMITS),
+ };
+};
diff --git a/packages/ee/server-only/stripe/create-team-customer.ts b/packages/ee/server-only/stripe/create-team-customer.ts
new file mode 100644
index 000000000..591c445af
--- /dev/null
+++ b/packages/ee/server-only/stripe/create-team-customer.ts
@@ -0,0 +1,20 @@
+import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
+import { stripe } from '@documenso/lib/server-only/stripe';
+
+type CreateTeamCustomerOptions = {
+ name: string;
+ email: string;
+};
+
+/**
+ * Create a Stripe customer for a given team.
+ */
+export const createTeamCustomer = async ({ name, email }: CreateTeamCustomerOptions) => {
+ return await stripe.customers.create({
+ name,
+ email,
+ metadata: {
+ type: STRIPE_CUSTOMER_TYPE.TEAM,
+ },
+ });
+};
diff --git a/packages/ee/server-only/stripe/delete-customer-payment-methods.ts b/packages/ee/server-only/stripe/delete-customer-payment-methods.ts
new file mode 100644
index 000000000..749c15763
--- /dev/null
+++ b/packages/ee/server-only/stripe/delete-customer-payment-methods.ts
@@ -0,0 +1,22 @@
+import { stripe } from '@documenso/lib/server-only/stripe';
+
+type DeleteCustomerPaymentMethodsOptions = {
+ customerId: string;
+};
+
+/**
+ * Delete all attached payment methods for a given customer.
+ */
+export const deleteCustomerPaymentMethods = async ({
+ customerId,
+}: DeleteCustomerPaymentMethodsOptions) => {
+ const paymentMethods = await stripe.paymentMethods.list({
+ customer: customerId,
+ });
+
+ await Promise.all(
+ paymentMethods.data.map(async (paymentMethod) =>
+ stripe.paymentMethods.detach(paymentMethod.id),
+ ),
+ );
+};
diff --git a/packages/ee/server-only/stripe/get-checkout-session.ts b/packages/ee/server-only/stripe/get-checkout-session.ts
index fd15d538a..7c89c1f8c 100644
--- a/packages/ee/server-only/stripe/get-checkout-session.ts
+++ b/packages/ee/server-only/stripe/get-checkout-session.ts
@@ -1,17 +1,21 @@
'use server';
+import type Stripe from 'stripe';
+
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
+ subscriptionMetadata?: Stripe.Metadata;
};
export const getCheckoutSession = async ({
customerId,
priceId,
returnUrl,
+ subscriptionMetadata,
}: GetCheckoutSessionOptions) => {
'use server';
@@ -26,6 +30,9 @@ export const getCheckoutSession = async ({
],
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
+ subscription_data: {
+ metadata: subscriptionMetadata,
+ },
});
return session.url;
diff --git a/packages/ee/server-only/stripe/get-community-plan-prices.ts b/packages/ee/server-only/stripe/get-community-plan-prices.ts
new file mode 100644
index 000000000..86c7f61bd
--- /dev/null
+++ b/packages/ee/server-only/stripe/get-community-plan-prices.ts
@@ -0,0 +1,13 @@
+import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
+
+import { getPricesByPlan } from './get-prices-by-plan';
+
+export const getCommunityPlanPrices = async () => {
+ return await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
+};
+
+export const getCommunityPlanPriceIds = async () => {
+ const prices = await getCommunityPlanPrices();
+
+ return prices.map((price) => price.id);
+};
diff --git a/packages/ee/server-only/stripe/get-customer.ts b/packages/ee/server-only/stripe/get-customer.ts
index c85488e6f..6e2d4f088 100644
--- a/packages/ee/server-only/stripe/get-customer.ts
+++ b/packages/ee/server-only/stripe/get-customer.ts
@@ -1,15 +1,19 @@
+import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
+/**
+ * Get a non team Stripe customer by email.
+ */
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
email,
});
- return foundStripeCustomers.data[0] ?? null;
+ return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null;
};
export const getStripeCustomerById = async (stripeCustomerId: string) => {
@@ -51,6 +55,7 @@ export const getStripeCustomerByUser = async (user: User) => {
email: user.email,
metadata: {
userId: user.id,
+ type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL,
},
});
}
@@ -78,6 +83,14 @@ export const getStripeCustomerByUser = async (user: User) => {
};
};
+export const getStripeCustomerIdByUser = async (user: User) => {
+ if (user.customerId !== null) {
+ return user.customerId;
+ }
+
+ return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id);
+};
+
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
const stripeSubscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
diff --git a/packages/ee/server-only/stripe/get-invoices.ts b/packages/ee/server-only/stripe/get-invoices.ts
new file mode 100644
index 000000000..f8f383921
--- /dev/null
+++ b/packages/ee/server-only/stripe/get-invoices.ts
@@ -0,0 +1,11 @@
+import { stripe } from '@documenso/lib/server-only/stripe';
+
+export type GetInvoicesOptions = {
+ customerId: string;
+};
+
+export const getInvoices = async ({ customerId }: GetInvoicesOptions) => {
+ return await stripe.invoices.list({
+ customer: customerId,
+ });
+};
diff --git a/packages/ee/server-only/stripe/get-portal-session.ts b/packages/ee/server-only/stripe/get-portal-session.ts
index 310cc1e47..275d166d8 100644
--- a/packages/ee/server-only/stripe/get-portal-session.ts
+++ b/packages/ee/server-only/stripe/get-portal-session.ts
@@ -4,7 +4,7 @@ import { stripe } from '@documenso/lib/server-only/stripe';
export type GetPortalSessionOptions = {
customerId: string;
- returnUrl: string;
+ returnUrl?: string;
};
export const getPortalSession = async ({ customerId, returnUrl }: GetPortalSessionOptions) => {
diff --git a/packages/ee/server-only/stripe/get-prices-by-interval.ts b/packages/ee/server-only/stripe/get-prices-by-interval.ts
index a5578a813..1b528706a 100644
--- a/packages/ee/server-only/stripe/get-prices-by-interval.ts
+++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts
@@ -9,12 +9,12 @@ export type PriceIntervals = Record {
+export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => {
let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
@@ -26,7 +26,7 @@ export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions =
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
- const filter = !type || product.metadata?.type === type;
+ const filter = !plan || product.metadata?.plan === plan;
// Filter out prices for products that are not active.
return product.active && filter;
diff --git a/packages/ee/server-only/stripe/get-prices-by-plan.ts b/packages/ee/server-only/stripe/get-prices-by-plan.ts
new file mode 100644
index 000000000..5c390b35a
--- /dev/null
+++ b/packages/ee/server-only/stripe/get-prices-by-plan.ts
@@ -0,0 +1,14 @@
+import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
+import { stripe } from '@documenso/lib/server-only/stripe';
+
+export const getPricesByPlan = async (
+ plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE],
+) => {
+ const { data: prices } = await stripe.prices.search({
+ query: `metadata['plan']:'${plan}' type:'recurring'`,
+ expand: ['data.product'],
+ limit: 100,
+ });
+
+ return prices;
+};
diff --git a/packages/ee/server-only/stripe/get-prices-by-type.ts b/packages/ee/server-only/stripe/get-prices-by-type.ts
deleted file mode 100644
index 22124562c..000000000
--- a/packages/ee/server-only/stripe/get-prices-by-type.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { stripe } from '@documenso/lib/server-only/stripe';
-
-export const getPricesByType = async (type: 'individual') => {
- const { data: prices } = await stripe.prices.search({
- query: `metadata['type']:'${type}' type:'recurring'`,
- expand: ['data.product'],
- limit: 100,
- });
-
- return prices;
-};
diff --git a/packages/ee/server-only/stripe/get-team-prices.ts b/packages/ee/server-only/stripe/get-team-prices.ts
new file mode 100644
index 000000000..5c3021b78
--- /dev/null
+++ b/packages/ee/server-only/stripe/get-team-prices.ts
@@ -0,0 +1,43 @@
+import type Stripe from 'stripe';
+
+import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
+import { AppError } from '@documenso/lib/errors/app-error';
+
+import { getPricesByPlan } from './get-prices-by-plan';
+
+export const getTeamPrices = async () => {
+ const prices = (await getPricesByPlan(STRIPE_PLAN_TYPE.TEAM)).filter((price) => price.active);
+
+ const monthlyPrice = prices.find((price) => price.recurring?.interval === 'month');
+ const yearlyPrice = prices.find((price) => price.recurring?.interval === 'year');
+ const priceIds = prices.map((price) => price.id);
+
+ if (!monthlyPrice || !yearlyPrice) {
+ throw new AppError('INVALID_CONFIG', 'Missing monthly or yearly price');
+ }
+
+ return {
+ monthly: {
+ friendlyInterval: 'Monthly',
+ interval: 'monthly',
+ ...extractPriceData(monthlyPrice),
+ },
+ yearly: {
+ friendlyInterval: 'Yearly',
+ interval: 'yearly',
+ ...extractPriceData(yearlyPrice),
+ },
+ priceIds,
+ } as const;
+};
+
+const extractPriceData = (price: Stripe.Price) => {
+ const product =
+ typeof price.product !== 'string' && !price.product.deleted ? price.product : null;
+
+ return {
+ priceId: price.id,
+ description: product?.description ?? '',
+ features: product?.features ?? [],
+ };
+};
diff --git a/packages/ee/server-only/stripe/transfer-team-subscription.ts b/packages/ee/server-only/stripe/transfer-team-subscription.ts
new file mode 100644
index 000000000..b4e0bd59a
--- /dev/null
+++ b/packages/ee/server-only/stripe/transfer-team-subscription.ts
@@ -0,0 +1,126 @@
+import type Stripe from 'stripe';
+
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { stripe } from '@documenso/lib/server-only/stripe';
+import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
+import { prisma } from '@documenso/prisma';
+import { type Subscription, type Team, type User } from '@documenso/prisma/client';
+
+import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
+import { getCommunityPlanPriceIds } from './get-community-plan-prices';
+import { getTeamPrices } from './get-team-prices';
+
+type TransferStripeSubscriptionOptions = {
+ /**
+ * The user to transfer the subscription to.
+ */
+ user: User & { Subscription: Subscription[] };
+
+ /**
+ * The team the subscription is associated with.
+ */
+ team: Team & { subscription?: Subscription | null };
+
+ /**
+ * Whether to clear any current payment methods attached to the team.
+ */
+ clearPaymentMethods: boolean;
+};
+
+/**
+ * Transfer the Stripe Team seats subscription from one user to another.
+ *
+ * Will create a new subscription for the new owner and cancel the old one.
+ *
+ * Returns the subscription that should be associated with the team, null if
+ * no subscription is needed (for community plan).
+ */
+export const transferTeamSubscription = async ({
+ user,
+ team,
+ clearPaymentMethods,
+}: TransferStripeSubscriptionOptions) => {
+ const teamCustomerId = team.customerId;
+
+ if (!teamCustomerId) {
+ throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
+ }
+
+ const [communityPlanIds, teamSeatPrices] = await Promise.all([
+ getCommunityPlanPriceIds(),
+ getTeamPrices(),
+ ]);
+
+ const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan(
+ user.Subscription,
+ communityPlanIds,
+ );
+
+ let teamSubscription: Stripe.Subscription | null = null;
+
+ if (team.subscription) {
+ teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
+
+ if (!teamSubscription) {
+ throw new Error('Could not find the current subscription.');
+ }
+
+ if (clearPaymentMethods) {
+ await deleteCustomerPaymentMethods({ customerId: teamCustomerId });
+ }
+ }
+
+ await stripe.customers.update(teamCustomerId, {
+ name: user.name ?? team.name,
+ email: user.email,
+ });
+
+ // If team subscription is required and the team does not have a subscription, create one.
+ if (teamSubscriptionRequired && !teamSubscription) {
+ const numberOfSeats = await prisma.teamMember.count({
+ where: {
+ teamId: team.id,
+ },
+ });
+
+ const teamSeatPriceId = teamSeatPrices.monthly.priceId;
+
+ teamSubscription = await stripe.subscriptions.create({
+ customer: teamCustomerId,
+ items: [
+ {
+ price: teamSeatPriceId,
+ quantity: numberOfSeats,
+ },
+ ],
+ metadata: {
+ teamId: team.id.toString(),
+ },
+ });
+ }
+
+ // If no team subscription is required, cancel the current team subscription if it exists.
+ if (!teamSubscriptionRequired && teamSubscription) {
+ try {
+ // Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount.
+ await stripe.subscriptions.update(teamSubscription.id, {
+ items: teamSubscription.items.data.map((item) => ({
+ id: item.id,
+ quantity: 0,
+ })),
+ });
+
+ await stripe.subscriptions.cancel(teamSubscription.id, {
+ invoice_now: true,
+ prorate: false,
+ });
+ } catch (e) {
+ // Do not error out since we can't easily undo the transfer.
+ // Todo: Teams - Alert us.
+ }
+
+ return null;
+ }
+
+ return teamSubscription;
+};
diff --git a/packages/ee/server-only/stripe/update-customer.ts b/packages/ee/server-only/stripe/update-customer.ts
new file mode 100644
index 000000000..78e223b48
--- /dev/null
+++ b/packages/ee/server-only/stripe/update-customer.ts
@@ -0,0 +1,18 @@
+import { stripe } from '@documenso/lib/server-only/stripe';
+
+type UpdateCustomerOptions = {
+ customerId: string;
+ name?: string;
+ email?: string;
+};
+
+export const updateCustomer = async ({ customerId, name, email }: UpdateCustomerOptions) => {
+ if (!name && !email) {
+ return;
+ }
+
+ return await stripe.customers.update(customerId, {
+ name,
+ email,
+ });
+};
diff --git a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts
new file mode 100644
index 000000000..e0fa95f3d
--- /dev/null
+++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts
@@ -0,0 +1,44 @@
+import type Stripe from 'stripe';
+
+import { stripe } from '@documenso/lib/server-only/stripe';
+
+export type UpdateSubscriptionItemQuantityOptions = {
+ subscriptionId: string;
+ quantity: number;
+ priceId: string;
+};
+
+export const updateSubscriptionItemQuantity = async ({
+ subscriptionId,
+ quantity,
+ priceId,
+}: UpdateSubscriptionItemQuantityOptions) => {
+ const subscription = await stripe.subscriptions.retrieve(subscriptionId);
+
+ const items = subscription.items.data.filter((item) => item.price.id === priceId);
+
+ if (items.length !== 1) {
+ throw new Error('Subscription does not contain required item');
+ }
+
+ const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
+ const oldQuantity = items[0].quantity;
+
+ if (oldQuantity === quantity) {
+ return;
+ }
+
+ const subscriptionUpdatePayload: Stripe.SubscriptionUpdateParams = {
+ items: items.map((item) => ({
+ id: item.id,
+ quantity,
+ })),
+ };
+
+ // Only invoice immediately when changing the quantity of yearly item.
+ if (hasYearlyItem) {
+ subscriptionUpdatePayload.proration_behavior = 'always_invoice';
+ }
+
+ await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
+};
diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts
index 047de7962..23705438a 100644
--- a/packages/ee/server-only/stripe/webhook/handler.ts
+++ b/packages/ee/server-only/stripe/webhook/handler.ts
@@ -3,8 +3,10 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
+import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
+import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
@@ -84,14 +86,9 @@ export const stripeWebhookHandler = async (
},
});
- if (!result?.id) {
- return res.status(500).json({
- success: false,
- message: 'User not found',
- });
+ if (result?.id) {
+ userId = result.id;
}
-
- userId = result.id;
}
const subscriptionId =
@@ -99,7 +96,7 @@ export const stripeWebhookHandler = async (
? session.subscription
: session.subscription?.id;
- if (!subscriptionId || Number.isNaN(userId)) {
+ if (!subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid session',
@@ -108,6 +105,24 @@ export const stripeWebhookHandler = async (
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
+ // Handle team creation after seat checkout.
+ if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
+ await handleTeamSeatCheckout({ subscription });
+
+ return res.status(200).json({
+ success: true,
+ message: 'Webhook received',
+ });
+ }
+
+ // Validate user ID.
+ if (!userId || Number.isNaN(userId)) {
+ return res.status(500).json({
+ success: false,
+ message: 'Invalid session or missing user ID',
+ });
+ }
+
await onSubscriptionUpdated({ userId, subscription });
return res.status(200).json({
@@ -124,6 +139,28 @@ export const stripeWebhookHandler = async (
? subscription.customer
: subscription.customer.id;
+ if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
+ const team = await prisma.team.findFirst({
+ where: {
+ customerId,
+ },
+ });
+
+ if (!team) {
+ return res.status(500).json({
+ success: false,
+ message: 'No team associated with subscription found',
+ });
+ }
+
+ await onSubscriptionUpdated({ teamId: team.id, subscription });
+
+ return res.status(200).json({
+ success: true,
+ message: 'Webhook received',
+ });
+ }
+
const result = await prisma.user.findFirst({
select: {
id: true,
@@ -182,6 +219,28 @@ export const stripeWebhookHandler = async (
});
}
+ if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
+ const team = await prisma.team.findFirst({
+ where: {
+ customerId,
+ },
+ });
+
+ if (!team) {
+ return res.status(500).json({
+ success: false,
+ message: 'No team associated with subscription found',
+ });
+ }
+
+ await onSubscriptionUpdated({ teamId: team.id, subscription });
+
+ return res.status(200).json({
+ success: true,
+ message: 'Webhook received',
+ });
+ }
+
const result = await prisma.user.findFirst({
select: {
id: true,
@@ -233,6 +292,28 @@ export const stripeWebhookHandler = async (
});
}
+ if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
+ const team = await prisma.team.findFirst({
+ where: {
+ customerId,
+ },
+ });
+
+ if (!team) {
+ return res.status(500).json({
+ success: false,
+ message: 'No team associated with subscription found',
+ });
+ }
+
+ await onSubscriptionUpdated({ teamId: team.id, subscription });
+
+ return res.status(200).json({
+ success: true,
+ message: 'Webhook received',
+ });
+ }
+
const result = await prisma.user.findFirst({
select: {
id: true,
@@ -282,3 +363,21 @@ export const stripeWebhookHandler = async (
});
}
};
+
+export type HandleTeamSeatCheckoutOptions = {
+ subscription: Stripe.Subscription;
+};
+
+const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => {
+ if (subscription.metadata?.pendingTeamId === undefined) {
+ throw new Error('Missing pending team ID');
+ }
+
+ const pendingTeamId = Number(subscription.metadata.pendingTeamId);
+
+ if (Number.isNaN(pendingTeamId)) {
+ throw new Error('Invalid pending team ID');
+ }
+
+ return await createTeamFromPendingTeam({ pendingTeamId, subscription }).then((team) => team.id);
+};
diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts
index d7ce7b062..8e2f00df8 100644
--- a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts
+++ b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts
@@ -2,23 +2,40 @@ import { match } from 'ts-pattern';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
+import type { Prisma } from '@documenso/prisma/client';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionUpdatedOptions = {
- userId: number;
+ userId?: number;
+ teamId?: number;
subscription: Stripe.Subscription;
};
export const onSubscriptionUpdated = async ({
userId,
+ teamId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
+ await prisma.subscription.upsert(
+ mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId),
+ );
+};
+
+export const mapStripeSubscriptionToPrismaUpsertAction = (
+ subscription: Stripe.Subscription,
+ userId?: number,
+ teamId?: number,
+): Prisma.SubscriptionUpsertArgs => {
+ if ((!userId && !teamId) || (userId && teamId)) {
+ throw new Error('Either userId or teamId must be provided.');
+ }
+
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
- await prisma.subscription.upsert({
+ return {
where: {
planId: subscription.id,
},
@@ -27,7 +44,8 @@ export const onSubscriptionUpdated = async ({
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
- userId,
+ userId: userId ?? null,
+ teamId: teamId ?? null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
@@ -37,5 +55,5 @@ export const onSubscriptionUpdated = async ({
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
- });
+ };
};
diff --git a/packages/email/static/add-user.png b/packages/email/static/add-user.png
new file mode 100644
index 000000000..abd337ceb
Binary files /dev/null and b/packages/email/static/add-user.png differ
diff --git a/packages/email/static/mail-open-alert.png b/packages/email/static/mail-open-alert.png
new file mode 100644
index 000000000..1511f0bc5
Binary files /dev/null and b/packages/email/static/mail-open-alert.png differ
diff --git a/packages/email/static/mail-open.png b/packages/email/static/mail-open.png
new file mode 100644
index 000000000..306313b03
Binary files /dev/null and b/packages/email/static/mail-open.png differ
diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx
index 216a3183d..b958e9029 100644
--- a/packages/email/template-components/template-document-invite.tsx
+++ b/packages/email/template-components/template-document-invite.tsx
@@ -1,3 +1,6 @@
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import type { RecipientRole } from '@documenso/prisma/client';
+
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps {
documentName: string;
signDocumentLink: string;
assetBaseUrl: string;
+ role: RecipientRole;
}
export const TemplateDocumentInvite = ({
@@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({
documentName,
signDocumentLink,
assetBaseUrl,
+ role,
}: TemplateDocumentInviteProps) => {
+ const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
+
return (
<>
- {inviterName} has invited you to sign
+ {inviterName} has invited you to {actionVerb.toLowerCase()}
"{documentName}"
- Continue by signing the document.
+ Continue by {progressiveVerb.toLowerCase()} the document.
@@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink}
>
- Sign Document
+ {actionVerb} Document
diff --git a/packages/email/template-components/template-image.tsx b/packages/email/template-components/template-image.tsx
new file mode 100644
index 000000000..8f821c10f
--- /dev/null
+++ b/packages/email/template-components/template-image.tsx
@@ -0,0 +1,17 @@
+import { Img } from '../components';
+
+export interface TemplateImageProps {
+ assetBaseUrl: string;
+ className?: string;
+ staticAsset: string;
+}
+
+export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => {
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return ;
+};
+
+export default TemplateImage;
diff --git a/packages/email/templates/confirm-email.tsx b/packages/email/templates/confirm-email.tsx
index b3acd1ecd..59c7add10 100644
--- a/packages/email/templates/confirm-email.tsx
+++ b/packages/email/templates/confirm-email.tsx
@@ -7,7 +7,7 @@ import { TemplateFooter } from '../template-components/template-footer';
export const ConfirmEmailTemplate = ({
confirmationLink,
- assetBaseUrl,
+ assetBaseUrl = 'http://localhost:3002',
}: TemplateConfirmationEmailProps) => {
const previewText = `Please confirm your email address`;
@@ -55,3 +55,5 @@ export const ConfirmEmailTemplate = ({