+
Full Name
null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
- viewedDocument({ token }).catch(() => null),
+ viewedDocument({ token, requestMetadata }).catch(() => null),
]);
- const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
-
if (!document || !document.documentData || !recipient) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
- const { documentData } = document;
+ const { documentData, documentMeta } = document;
const { user } = await getServerComponentSession();
@@ -65,7 +68,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED
) {
- redirect(`/sign/${token}/complete`);
+ documentMeta?.redirectUrl
+ ? redirect(documentMeta.redirectUrl)
+ : redirect(`/sign/${token}/complete`);
}
if (documentMeta?.password) {
@@ -133,7 +138,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
-
+
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..26b1d7c91
--- /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 { DocumentPageView } from '~/app/(dashboard)/documents/[id]/document-page-view';
+
+export type DocumentPageProps = {
+ params: {
+ id: string;
+ teamUrl: string;
+ };
+};
+
+export default async function DocumentPage({ params }: DocumentPageProps) {
+ const { teamUrl } = params;
+
+ const { user } = await getRequiredServerComponentSession();
+ const team = await getTeamByUrl({ userId: user.id, teamUrl });
+
+ return
;
+}
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..d3d5b5bee
--- /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..92f89c01e
--- /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: Pick | 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/(teams)/t/[teamUrl]/templates/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
new file mode 100644
index 000000000..3fe7cbf67
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+
+import type { TemplatePageViewProps } from '~/app/(dashboard)/templates/[id]/template-page-view';
+import { TemplatePageView } from '~/app/(dashboard)/templates/[id]/template-page-view';
+
+type TeamTemplatePageProps = {
+ params: TemplatePageViewProps['params'] & {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) {
+ const { teamUrl } = params;
+
+ const { user } = await getRequiredServerComponentSession();
+ const team = await getTeamByUrl({ userId: user.id, teamUrl });
+
+ return ;
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
new file mode 100644
index 000000000..6954d8e2d
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+
+import type { TemplatesPageViewProps } from '~/app/(dashboard)/templates/templates-page-view';
+import { TemplatesPageView } from '~/app/(dashboard)/templates/templates-page-view';
+
+type TeamTemplatesPageProps = {
+ searchParams?: TemplatesPageViewProps['searchParams'];
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamTemplatesPage({
+ searchParams = {},
+ params,
+}: TeamTemplatesPageProps) {
+ const { teamUrl } = params;
+
+ const { user } = await getRequiredServerComponentSession();
+ const team = await getTeamByUrl({ userId: user.id, teamUrl });
+
+ return ;
+}
diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx
index 1332a3f37..8331e7c03 100644
--- a/apps/web/src/app/(unauthenticated)/signin/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx
@@ -1,7 +1,9 @@
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';
@@ -9,7 +11,20 @@ export const metadata: Metadata = {
title: 'Sign In',
};
-export default function SignInPage() {
+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
@@ -18,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 c6d49f891..dbbbcdba9 100644
--- a/apps/web/src/app/(unauthenticated)/signup/page.tsx
+++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx
@@ -3,6 +3,7 @@ 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';
@@ -10,11 +11,24 @@ export const metadata: Metadata = {
title: 'Sign Up',
};
-export default function SignUpPage() {
+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
@@ -24,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)/unverified-account/page.tsx b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx
new file mode 100644
index 000000000..f4b8b90d7
--- /dev/null
+++ b/apps/web/src/app/(unauthenticated)/unverified-account/page.tsx
@@ -0,0 +1,27 @@
+import { Mails } from 'lucide-react';
+
+import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
+
+export default function UnverifiedAccount() {
+ return (
+
+
+
+
+
+
Confirm email
+
+
+ To gain access to your account, please confirm your email address by clicking on the
+ confirmation link from your inbox.
+
+
+
+ If you don't find the confirmation link in your inbox, you can request a new one below.
+
+
+
+
+
+ );
+}
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..9eef1f4bd 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);
@@ -51,11 +55,13 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
{navigationLinks.map(({ href, label }) => (
diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx
index ba35671e6..65bb63230 100644
--- a/apps/web/src/components/(dashboard)/layout/header.tsx
+++ b/apps/web/src/components/(dashboard)/layout/header.tsx
@@ -1,23 +1,40 @@
'use client';
-import type { HTMLAttributes } from 'react';
-import { useEffect, useState } from 'react';
+import { type HTMLAttributes, useEffect, useState } from 'react';
import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { MenuIcon, SearchIcon } from 'lucide-react';
+
+import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
+import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
+import { getRootHref } from '@documenso/lib/utils/params';
import type { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Logo } from '~/components/branding/logo';
+import { CommandMenu } from '../common/command-menu';
import { DesktopNav } from './desktop-nav';
+import { MenuSwitcher } from './menu-switcher';
+import { MobileNavigation } from './mobile-navigation';
import { ProfileDropdown } from './profile-dropdown';
export type HeaderProps = HTMLAttributes & {
user: User;
+ teams: GetTeamsResponse;
};
-export const Header = ({ className, user, ...props }: HeaderProps) => {
+export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
+ const params = useParams();
+
+ const { getFlag } = useFeatureFlags();
+
+ const isTeamsEnabled = getFlag('app_teams');
+
+ const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
+ const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
@@ -30,6 +47,34 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
return () => window.removeEventListener('scroll', onScroll);
}, []);
+ if (!isTeamsEnabled) {
+ return (
+ 5 && 'border-b-border',
+ className,
+ )}
+ {...props}
+ >
+
+
+ );
+ }
+
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..195716d64
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
@@ -0,0 +1,230 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
+import { signOut } from 'next-auth/react';
+
+import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
+import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
+import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import type { User } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+
+export type MenuSwitcherProps = {
+ user: User;
+ teams: GetTeamsResponse;
+};
+
+export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => {
+ const pathname = usePathname();
+
+ const isUserAdmin = isAdmin(user);
+
+ const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
+ initialData: initialTeamsData,
+ });
+
+ const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
+
+ const isPathTeamUrl = (teamUrl: string) => {
+ if (!pathname || !pathname.startsWith(`/t/`)) {
+ return false;
+ }
+
+ return pathname.split('/')[2] === teamUrl;
+ };
+
+ const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
+
+ const formatAvatarFallback = (teamName?: string) => {
+ if (teamName !== undefined) {
+ return teamName.slice(0, 1).toUpperCase();
+ }
+
+ return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
+ };
+
+ const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
+ if (!team) {
+ return 'Personal Account';
+ }
+
+ if (team.ownerUserId === user.id) {
+ return 'Owner';
+ }
+
+ return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role];
+ };
+
+ /**
+ * Formats the redirect URL so we can switch between documents and templates page
+ * seemlessly between teams and personal accounts.
+ */
+ const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
+ const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/';
+
+ const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
+
+ if (currentPathname === '/templates') {
+ return `${baseUrl}templates`;
+ }
+
+ return baseUrl;
+ };
+
+ return (
+
+
+
+
+ }
+ />
+
+
+
+
+ {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..a6009e7b5
--- /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',
+ },
+ ];
+
+ 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
index f2432c071..a767b9700 100644
--- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
+++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
@@ -20,7 +20,7 @@ import { LuGithub } from 'react-icons/lu';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
-import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
@@ -51,7 +51,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const isBillingEnabled = getFlag('app_billing');
const avatarFallback = user.name
- ? recipientInitials(user.name)
+ ? extractInitials(user.name)
: user.email.slice(0, 1).toUpperCase();
return (
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)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
index f4b2aae5e..572c91c76 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';
@@ -19,6 +19,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
+ const isTeamsEnabled = getFlag('app_teams');
return (
@@ -35,6 +36,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+ {isTeamsEnabled && (
+
+
+
+ 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..291c941f6 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';
@@ -19,6 +19,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const { getFlag } = useFeatureFlags();
const isBillingEnabled = getFlag('app_billing');
+ const isTeamsEnabled = getFlag('app_teams');
return (
{
+ {isTeamsEnabled && (
+
+
+
+ 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..f6ca99bbd
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx
@@ -0,0 +1,178 @@
+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,
+ })
+ }
+ >
+ {selectedPrice.interval === 'monthly' ? 'Checkout' : 'Coming soon'}
+
+
+
+ )}
+
+
+ );
+};
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 316272e34..3aa0e123e 100644
--- a/apps/web/src/components/forms/2fa/authenticator-app.tsx
+++ b/apps/web/src/components/forms/2fa/authenticator-app.tsx
@@ -30,13 +30,11 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps)
!open && setModalState(null)}
/>
!open && setModalState(null)}
/>
diff --git a/apps/web/src/components/forms/send-confirmation-email.tsx b/apps/web/src/components/forms/send-confirmation-email.tsx
new file mode 100644
index 000000000..33247bf9f
--- /dev/null
+++ b/apps/web/src/components/forms/send-confirmation-email.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export const ZSendConfirmationEmailFormSchema = z.object({
+ email: z.string().email().min(1),
+});
+
+export type TSendConfirmationEmailFormSchema = z.infer;
+
+export type SendConfirmationEmailFormProps = {
+ className?: string;
+};
+
+export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFormProps) => {
+ const { toast } = useToast();
+
+ const form = useForm({
+ values: {
+ email: '',
+ },
+ resolver: zodResolver(ZSendConfirmationEmailFormSchema),
+ });
+
+ const isSubmitting = form.formState.isSubmitting;
+
+ const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation();
+
+ const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => {
+ try {
+ await sendConfirmationEmail({ email });
+
+ toast({
+ title: 'Confirmation email sent',
+ description:
+ 'A confirmation email has been sent, and it should arrive in your inbox shortly.',
+ duration: 5000,
+ });
+
+ form.reset();
+ } catch (err) {
+ toast({
+ title: 'An error occurred while sending your confirmation email',
+ description: 'Please try again and make sure you enter the correct email address.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx
index b3e4ea019..ec690a568 100644
--- a/apps/web/src/components/forms/signin.tsx
+++ b/apps/web/src/components/forms/signin.tsx
@@ -2,6 +2,8 @@
import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -38,6 +40,8 @@ const ERROR_MESSAGES: Partial> = {
'This account appears to be using a social login method, please sign in using that method',
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
+ [ErrorCode.UNVERIFIED_EMAIL]:
+ 'This account has not been verified. Please verify your account before signing in.',
};
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
@@ -55,13 +59,15 @@ 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);
+ const router = useRouter();
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
@@ -69,7 +75,7 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
const form = useForm({
values: {
- email: '',
+ email: initialEmail ?? '',
password: '',
totpCode: '',
backupCode: '',
@@ -129,6 +135,17 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
const errorMessage = ERROR_MESSAGES[result.error];
+ if (result.error === ErrorCode.UNVERIFIED_EMAIL) {
+ router.push(`/unverified-account`);
+
+ toast({
+ title: 'Unable to sign in',
+ description: errorMessage ?? 'An unknown error occurred',
+ });
+
+ return;
+ }
+
toast({
variant: 'destructive',
title: 'Unable to sign in',
diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx
index f38ab15d1..7082bcee3 100644
--- a/apps/web/src/components/forms/signup.tsx
+++ b/apps/web/src/components/forms/signup.tsx
@@ -1,5 +1,7 @@
'use client';
+import { useRouter } from 'next/navigation';
+
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -48,17 +50,19 @@ 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();
const form = useForm({
values: {
name: '',
- email: '',
+ email: initialEmail ?? '',
password: '',
signature: '',
},
@@ -73,10 +77,13 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) =
try {
await signup({ name, email, password, signature });
- await signIn('credentials', {
- email,
- password,
- callbackUrl: SIGN_UP_REDIRECT_PATH,
+ router.push(`/unverified-account`);
+
+ toast({
+ title: 'Registration Successful',
+ description:
+ 'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
+ duration: 5000,
});
analytics.capture('App: User Sign Up', {
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/package-lock.json b/package-lock.json
index 9012d3f29..c4d4c1e5d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4886,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",
@@ -4897,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",
@@ -4928,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",
@@ -14610,6 +14503,7 @@
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz",
"integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==",
+ "peer": true,
"engines": {
"node": ">=6.0.0"
}
@@ -19602,14 +19496,14 @@
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
- "nodemailer": "^6.9.3",
+ "nodemailer": "^6.9.9",
"react-email": "^1.9.5",
"resend": "^2.0.0"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
- "@types/nodemailer": "^6.4.8",
+ "@types/nodemailer": "^6.4.14",
"tsup": "^7.1.0"
}
},
@@ -19627,6 +19521,14 @@
"node": ">=16.0.0"
}
},
+ "packages/email/node_modules/nodemailer": {
+ "version": "6.9.9",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz",
+ "integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"packages/eslint-config": {
"name": "@documenso/eslint-config",
"version": "0.0.0",
@@ -19750,13 +19652,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",
@@ -19864,7 +19772,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",
@@ -19874,7 +19782,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/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts
new file mode 100644
index 000000000..53edc705d
--- /dev/null
+++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts
@@ -0,0 +1,205 @@
+import { expect, test } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
+import { seedTemplate } from '@documenso/prisma/seed/templates';
+
+import { manualLogin } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+test('[TEMPLATES]: view templates', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const owner = team.owner;
+ const teamMemberUser = team.members[1].user;
+
+ // Should only be visible to the owner in personal templates.
+ await seedTemplate({
+ title: 'Personal template',
+ userId: owner.id,
+ });
+
+ // Should be visible to team members.
+ await seedTemplate({
+ title: 'Team template 1',
+ userId: owner.id,
+ teamId: team.id,
+ });
+
+ // Should be visible to team members.
+ await seedTemplate({
+ title: 'Team template 2',
+ userId: teamMemberUser.id,
+ teamId: team.id,
+ });
+
+ await manualLogin({
+ page,
+ email: owner.email,
+ redirectPath: '/templates',
+ });
+
+ // Owner should see both team templates.
+ await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
+ await expect(page.getByRole('main')).toContainText('Showing 2 results');
+
+ // Only should only see their personal template.
+ await page.goto(`${WEBAPP_BASE_URL}/templates`);
+ await expect(page.getByRole('main')).toContainText('Showing 1 result');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEMPLATES]: delete template', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const owner = team.owner;
+ const teamMemberUser = team.members[1].user;
+
+ // Should only be visible to the owner in personal templates.
+ await seedTemplate({
+ title: 'Personal template',
+ userId: owner.id,
+ });
+
+ // Should be visible to team members.
+ await seedTemplate({
+ title: 'Team template 1',
+ userId: owner.id,
+ teamId: team.id,
+ });
+
+ // Should be visible to team members.
+ await seedTemplate({
+ title: 'Team template 2',
+ userId: teamMemberUser.id,
+ teamId: team.id,
+ });
+
+ await manualLogin({
+ page,
+ email: owner.email,
+ redirectPath: '/templates',
+ });
+
+ // Owner should be able to delete their personal template.
+ await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByRole('button', { name: 'Delete' }).click();
+ await expect(page.getByText('Template deleted').first()).toBeVisible();
+
+ // Team member should be able to delete all templates.
+ await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
+
+ for (const template of ['Team template 1', 'Team template 2']) {
+ await page
+ .getByRole('row', { name: template })
+ .getByRole('cell', { name: 'Use Template' })
+ .getByRole('button')
+ .nth(1)
+ .click();
+
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByRole('button', { name: 'Delete' }).click();
+ await expect(page.getByText('Template deleted').first()).toBeVisible();
+ }
+
+ await unseedTeam(team.url);
+});
+
+test('[TEMPLATES]: duplicate template', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const owner = team.owner;
+ const teamMemberUser = team.members[1].user;
+
+ // Should only be visible to the owner in personal templates.
+ await seedTemplate({
+ title: 'Personal template',
+ userId: owner.id,
+ });
+
+ // Should be visible to team members.
+ await seedTemplate({
+ title: 'Team template 1',
+ userId: teamMemberUser.id,
+ teamId: team.id,
+ });
+
+ await manualLogin({
+ page,
+ email: owner.email,
+ redirectPath: '/templates',
+ });
+
+ // Duplicate personal template.
+ await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
+ await page.getByRole('menuitem', { name: 'Duplicate' }).click();
+ await page.getByRole('button', { name: 'Duplicate' }).click();
+ await expect(page.getByText('Template duplicated').first()).toBeVisible();
+ await expect(page.getByRole('main')).toContainText('Showing 2 results');
+
+ await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
+
+ // Duplicate team template.
+ await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
+ await page.getByRole('menuitem', { name: 'Duplicate' }).click();
+ await page.getByRole('button', { name: 'Duplicate' }).click();
+ await expect(page.getByText('Template duplicated').first()).toBeVisible();
+ await expect(page.getByRole('main')).toContainText('Showing 2 results');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEMPLATES]: use template', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const owner = team.owner;
+ const teamMemberUser = team.members[1].user;
+
+ // Should only be visible to the owner in personal templates.
+ await seedTemplate({
+ title: 'Personal template',
+ userId: owner.id,
+ });
+
+ // Should be visible to team members.
+ await seedTemplate({
+ title: 'Team template 1',
+ userId: teamMemberUser.id,
+ teamId: team.id,
+ });
+
+ await manualLogin({
+ page,
+ email: owner.email,
+ redirectPath: '/templates',
+ });
+
+ // Use personal template.
+ await page.getByRole('button', { name: 'Use Template' }).click();
+ await page.waitForURL(/documents/);
+ await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
+ await page.waitForURL('/documents');
+ await expect(page.getByRole('main')).toContainText('Showing 1 result');
+
+ await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
+
+ // Use team template.
+ await page.getByRole('button', { name: 'Use Template' }).click();
+ await page.waitForURL(/\/t\/.+\/documents/);
+ await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
+ await page.waitForURL(`/t/${team.url}/documents`);
+ await expect(page.getByRole('main')).toContainText('Showing 1 result');
+
+ 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 45b6dea03..40ee5e768 100644
--- a/packages/app-tests/e2e/test-auth-flow.spec.ts
+++ b/packages/app-tests/e2e/test-auth-flow.spec.ts
@@ -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/package.json b/packages/email/package.json
index d41a4c24c..984ea3d4c 100644
--- a/packages/email/package.json
+++ b/packages/email/package.json
@@ -35,14 +35,14 @@
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
- "nodemailer": "^6.9.3",
+ "nodemailer": "^6.9.9",
"react-email": "^1.9.5",
"resend": "^2.0.0"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
- "@types/nodemailer": "^6.4.8",
+ "@types/nodemailer": "^6.4.14",
"tsup": "^7.1.0"
}
}
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-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 = ({