c1PV_R*@^RR?(+_<6bYb>Qah|le5^QA-PRfj)?xkPc}2PtlT}$gmRYu?kBJ1iT5s%yV3JLx&_PC&B$Y{4
zIhuFXHqh(dS#ellgxSM-s3CBt*I^@Wq@GJ0>Q*Q
zLpG%PhrBo{jeW`vmQGSU=OL-^Ntu@8vwFburj7IPTzm>3HVH;M+uPd<*ZiX(7Owf*
z1?*RvaDQcGklM
z4f7d*QF`72j@v=o^=ev0%WE*MI)Pxoc!@v8bGNgurd#&WVTSoe@@ib!Fa#Iy6SM{@YiQQntX!Fg
zSEN;ZO)T$iFu=}SXgqoM(9n?IKb_@;Y)yHa<+;e%N~Kakbs(^EU#~lF$!uHPl($(o
zJ#`Jj$46!hq^W7Ma;SIoXgM5}@hRbuRD`wNBW;LRrfa!c3UaL^osxEmbLB9_fbS~ll-)B$
zf_gg9g9<^3LM#S2F4LA@D_zAemaGJV_ze!ybbuqYpmddRre^jkEKQEfzOLGt)J9Q_
zps@d8(hx>+(Q?0Ejl7`i*7?8>b=joam8)Y325hNRRY(XTL|H`0LbVAF(-1+Vif~G)
zP!D-w6+~HiQ|+jhW6P*6d(Gd_8Sjbf8-nrmAVV(beAsZEWdH?T*Rlz+9j)m~r)#+#
zIg7Lc$O5gBhF8b`y6)E*9;bD1h#yV@@{i847CQ}2f*o=R`C4S33%ft;dQcqI?zh7P
r9fzHBaBy&NaBy&NaBy&NsEOYJQ|7+=Yxnzq00000NkvXXu0mjfB)pO|
literal 0
HcmV?d00001
diff --git a/apps/web/public/static/mail-open-alert.png b/apps/web/public/static/mail-open-alert.png
new file mode 100644
index 0000000000000000000000000000000000000000..1511f0bc539302bf3fc9de7e18283d8882861ffb
GIT binary patch
literal 3818
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
BjST<*
literal 0
HcmV?d00001
diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
index 83ad81ca1..0fc660968 100644
--- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
+++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx
@@ -7,9 +7,9 @@ import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
-import { FindResultSet } from '@documenso/lib/types/find-result-set';
-import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
-import { Document, User } from '@documenso/prisma/client';
+import type { FindResultSet } from '@documenso/lib/types/find-result-set';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import type { Document, User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
- ? recipientInitials(row.original.User.name)
+ ? extractInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
diff --git a/packages/ui/primitives/multiselect-combobox.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx
similarity index 95%
rename from packages/ui/primitives/multiselect-combobox.tsx
rename to apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx
index bac87ce0b..9a25af897 100644
--- a/packages/ui/primitives/multiselect-combobox.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx
@@ -19,7 +19,7 @@ type ComboboxProps = {
onChange: (_values: string[]) => void;
};
-const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
+const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
const [open, setOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState([]);
const dbRoles = Object.values(Role);
@@ -79,4 +79,4 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
);
};
-export { MultiSelectCombobox };
+export { MultiSelectRoleCombobox };
diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
index 9ae270d28..3bd909623 100644
--- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
@@ -18,9 +18,10 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
-import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
import { useToast } from '@documenso/ui/primitives/use-toast';
+import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
+
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer;
@@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
Roles
- onChange(values)}
/>
diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx
index 069378274..577e0739a 100644
--- a/apps/web/src/app/(dashboard)/admin/users/page.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx
@@ -1,4 +1,5 @@
-import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
+import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
+import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { UsersDataTable } from './data-table-users';
import { search } from './fetch-users.actions';
@@ -18,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
- getPricesByType('individual'),
+ getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx
new file mode 100644
index 000000000..3a46ed5e7
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx
@@ -0,0 +1,131 @@
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+
+import { ChevronLeft, Users2 } from 'lucide-react';
+
+import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
+import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
+import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
+import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Team } from '@documenso/prisma/client';
+import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
+import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
+
+import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
+import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
+import { DocumentStatus } from '~/components/formatter/document-status';
+
+export type DocumentPageViewProps = {
+ params: {
+ id: string;
+ };
+ team?: Team;
+};
+
+export default async function DocumentPageView({ params, team }: DocumentPageViewProps) {
+ const { id } = params;
+
+ const documentId = Number(id);
+
+ const documentRootPath = formatDocumentsPath(team?.url);
+
+ if (!documentId || Number.isNaN(documentId)) {
+ redirect(documentRootPath);
+ }
+
+ const { user } = await getRequiredServerComponentSession();
+
+ const document = await getDocumentById({
+ id: documentId,
+ userId: user.id,
+ teamId: team?.id,
+ }).catch(() => null);
+
+ if (!document || !document.documentData) {
+ redirect(documentRootPath);
+ }
+
+ const { documentData, documentMeta } = document;
+
+ if (documentMeta?.password) {
+ const key = DOCUMENSO_ENCRYPTION_KEY;
+
+ if (!key) {
+ throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
+ }
+
+ const securePassword = Buffer.from(
+ symmetricDecrypt({
+ key,
+ data: documentMeta.password,
+ }),
+ ).toString('utf-8');
+
+ documentMeta.password = securePassword;
+ }
+
+ const [recipients, fields] = await Promise.all([
+ getRecipientsForDocument({
+ documentId,
+ userId: user.id,
+ }),
+ getFieldsForDocument({
+ documentId,
+ userId: user.id,
+ }),
+ ]);
+
+ return (
+
+
+
+ Documents
+
+
+
+ {document.title}
+
+
+
+
+
+ {recipients.length > 0 && (
+
+
+
+
+ {recipients.length} Recipient(s)
+
+
+ )}
+
+
+ {document.status !== InternalDocumentStatus.COMPLETED && (
+
+ )}
+
+ {document.status === InternalDocumentStatus.COMPLETED && (
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
index af1877a64..e6cbd6fd4 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
@@ -32,6 +32,7 @@ export type EditDocumentFormProps = {
documentMeta: DocumentMeta | null;
fields: Field[];
documentData: DocumentData;
+ documentRootPath: string;
};
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
@@ -45,6 +46,7 @@ export const EditDocumentForm = ({
documentMeta,
user: _user,
documentData,
+ documentRootPath,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
@@ -168,7 +170,7 @@ export const EditDocumentForm = ({
duration: 5000,
});
- router.push('/documents');
+ router.push(documentRootPath);
} catch (err) {
console.error(err);
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx
index 44f3991d8..e7a34889e 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx
@@ -1,20 +1,4 @@
-import Link from 'next/link';
-import { redirect } from 'next/navigation';
-
-import { ChevronLeft, Users2 } from 'lucide-react';
-
-import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
-import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
-import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
-import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
-import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
-import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
-import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
-import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
-
-import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
-import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
-import { DocumentStatus } from '~/components/formatter/document-status';
+import DocumentPageView from './document-page-view';
export type DocumentPageProps = {
params: {
@@ -22,103 +6,6 @@ export type DocumentPageProps = {
};
};
-export default async function DocumentPage({ params }: DocumentPageProps) {
- const { id } = params;
-
- const documentId = Number(id);
-
- if (!documentId || Number.isNaN(documentId)) {
- redirect('/documents');
- }
-
- const { user } = await getRequiredServerComponentSession();
-
- const document = await getDocumentById({
- id: documentId,
- userId: user.id,
- }).catch(() => null);
-
- if (!document || !document.documentData) {
- redirect('/documents');
- }
-
- const { documentData, documentMeta } = document;
-
- if (documentMeta?.password) {
- const key = DOCUMENSO_ENCRYPTION_KEY;
-
- if (!key) {
- throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
- }
-
- const securePassword = Buffer.from(
- symmetricDecrypt({
- key,
- data: documentMeta.password,
- }),
- ).toString('utf-8');
-
- documentMeta.password = securePassword;
- }
-
- const [recipients, fields] = await Promise.all([
- getRecipientsForDocument({
- documentId,
- userId: user.id,
- }),
- getFieldsForDocument({
- documentId,
- userId: user.id,
- }),
- ]);
-
- return (
-
-
-
- Documents
-
-
-
- {document.title}
-
-
-
-
-
- {recipients.length > 0 && (
-
-
-
-
- {recipients.length} Recipient(s)
-
-
- )}
-
-
- {document.status !== InternalDocumentStatus.COMPLETED && (
-
- )}
-
- {document.status === InternalDocumentStatus.COMPLETED && (
-
-
-
- )}
-
- );
+export default function DocumentPage({ params }: DocumentPageProps) {
+ return ;
}
diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
index 7fabeef95..e8e3d6130 100644
--- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx
@@ -10,6 +10,7 @@ import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
+import type { Team } from '@documenso/prisma/client';
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -39,8 +40,11 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
- document: Document;
+ document: Document & {
+ team: Pick | null;
+ };
recipients: Recipient[];
+ team?: Pick;
};
export const ZResendDocumentFormSchema = z.object({
@@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer {
const { data: session } = useSession();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
+ const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =
- !isOwner ||
+ (!isOwner && !isCurrentTeamDocument) ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
@@ -82,7 +88,7 @@ export const ResendDocumentActionItem = ({
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
- await resendDocument({ documentId: document.id, recipients });
+ await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
toast({
title: 'Document re-sent',
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
index ecddf1190..78ffd0b3b 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx
@@ -7,7 +7,8 @@ import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
-import type { Document, Recipient, User } from '@documenso/prisma/client';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
@@ -18,10 +19,12 @@ export type DataTableActionButtonProps = {
row: Document & {
User: Pick;
Recipient: Recipient[];
+ team: Pick | null;
};
+ team?: Pick;
};
-export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
+export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@@ -38,6 +41,9 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
+ const isCurrentTeamDocument = team && row.team?.url === team.url;
+
+ const documentsPath = formatDocumentsPath(team?.url);
const onDownloadClick = async () => {
try {
@@ -46,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
+ teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
@@ -81,15 +88,19 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
isPending,
isComplete,
isSigned,
+ isCurrentTeamDocument,
})
- .with({ isOwner: true, isDraft: true }, () => (
-
-
-
- Edit
-
-
- ))
+ .with(
+ isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
+ () => (
+
+
+
+ Edit
+
+
+ ),
+ )
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx
index e1d9b64bb..b7d2cf452 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx
@@ -20,8 +20,9 @@ import {
import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
-import type { Document, Recipient, User } from '@documenso/prisma/client';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
+import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -42,10 +43,12 @@ export type DataTableActionDropdownProps = {
row: Document & {
User: Pick;
Recipient: Recipient[];
+ team: Pick | null;
};
+ team?: Pick;
};
-export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
+export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@@ -65,6 +68,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
+ const isCurrentTeamDocument = team && row.team?.url === team.url;
+
+ const documentsPath = formatDocumentsPath(team?.url);
const onDownloadClick = async () => {
try {
@@ -73,6 +79,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
+ teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
@@ -134,8 +141,8 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
)}
-
-
+
+
Edit
@@ -163,7 +170,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Share
-
+
)}
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-sender-filter.tsx b/apps/web/src/app/(dashboard)/documents/data-table-sender-filter.tsx
new file mode 100644
index 000000000..6c66153a7
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/data-table-sender-filter.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
+import { parseToIntegerArray } from '@documenso/lib/utils/params';
+import { trpc } from '@documenso/trpc/react';
+import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
+
+type DataTableSenderFilterProps = {
+ teamId: number;
+};
+
+export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ const isMounted = useIsMounted();
+
+ const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
+
+ const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({
+ teamId,
+ });
+
+ const comboBoxOptions = (data ?? []).map((member) => ({
+ label: member.user.name ?? member.user.email,
+ value: member.user.id,
+ }));
+
+ const onChange = (newSenderIds: number[]) => {
+ if (!pathname) {
+ return;
+ }
+
+ const params = new URLSearchParams(searchParams?.toString());
+
+ params.set('senderIds', newSenderIds.join(','));
+
+ if (newSenderIds.length === 0) {
+ params.delete('senderIds');
+ }
+
+ router.push(`${pathname}?${params.toString()}`, { scroll: false });
+ };
+
+ return (
+
+ Sender: All
+
+ }
+ enableClearAllButton={true}
+ inputPlaceholder="Search"
+ loading={!isMounted || isInitialLoading}
+ options={comboBoxOptions}
+ selectedValues={senderIds}
+ onChange={onChange}
+ />
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx
index c8adb1422..13b85d526 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx
@@ -7,7 +7,7 @@ import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
-import type { Document, Recipient, User } from '@documenso/prisma/client';
+import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -25,11 +25,18 @@ export type DocumentsDataTableProps = {
Document & {
Recipient: Recipient[];
User: Pick;
+ team: Pick | null;
}
>;
+ showSenderColumn?: boolean;
+ team?: Pick;
};
-export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
+export const DocumentsDataTable = ({
+ results,
+ showSenderColumn,
+ team,
+}: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
@@ -61,6 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
header: 'Title',
cell: ({ row }) => ,
},
+ {
+ id: 'sender',
+ header: 'Sender',
+ cell: ({ row }) => row.original.User.name ?? row.original.User.email,
+ },
{
header: 'Recipient',
accessorKey: 'recipient',
@@ -79,8 +91,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
-
-
+
+
),
},
@@ -90,6 +102,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
+ columnVisibility={{
+ sender: Boolean(showSenderColumn),
+ }}
>
{(table) => }
diff --git a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
new file mode 100644
index 000000000..ead3e8f4f
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
@@ -0,0 +1,158 @@
+import Link from 'next/link';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
+import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
+import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
+import { getStats } from '@documenso/lib/server-only/document/get-stats';
+import { parseToIntegerArray } from '@documenso/lib/utils/params';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Team, TeamEmail } from '@documenso/prisma/client';
+import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
+import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
+import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
+import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
+
+import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
+import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
+import { DocumentStatus } from '~/components/formatter/document-status';
+
+import { DocumentsDataTable } from './data-table';
+import { DataTableSenderFilter } from './data-table-sender-filter';
+import { EmptyDocumentState } from './empty-state';
+import { UploadDocument } from './upload-document';
+
+export type DocumentsPageViewProps = {
+ searchParams?: {
+ status?: ExtendedDocumentStatus;
+ period?: PeriodSelectorValue;
+ page?: string;
+ perPage?: string;
+ senderIds?: string;
+ };
+ team?: Team & { teamEmail?: TeamEmail | null };
+};
+
+export default async function DocumentsPageView({
+ searchParams = {},
+ team,
+}: DocumentsPageViewProps) {
+ const { user } = await getRequiredServerComponentSession();
+
+ const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
+ const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
+ const page = Number(searchParams.page) || 1;
+ const perPage = Number(searchParams.perPage) || 20;
+ const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
+ const currentTeam = team ? { id: team.id, url: team.url } : undefined;
+
+ const getStatOptions: GetStatsInput = {
+ user,
+ period,
+ };
+
+ if (team) {
+ getStatOptions.team = {
+ teamId: team.id,
+ teamEmail: team.teamEmail?.email,
+ senderIds,
+ };
+ }
+
+ const stats = await getStats(getStatOptions);
+
+ const results = await findDocuments({
+ userId: user.id,
+ teamId: team?.id,
+ status,
+ orderBy: {
+ column: 'createdAt',
+ direction: 'desc',
+ },
+ page,
+ perPage,
+ period,
+ senderIds,
+ });
+
+ const getTabHref = (value: typeof status) => {
+ const params = new URLSearchParams(searchParams);
+
+ params.set('status', value);
+
+ if (params.has('page')) {
+ params.delete('page');
+ }
+
+ return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
+ };
+
+ return (
+
+
+
+
+
+ {team && (
+
+
+ {team.name.slice(0, 1)}
+
+
+ )}
+
+
Documents
+
+
+
+
+
+ {[
+ ExtendedDocumentStatus.INBOX,
+ ExtendedDocumentStatus.PENDING,
+ ExtendedDocumentStatus.COMPLETED,
+ ExtendedDocumentStatus.DRAFT,
+ ExtendedDocumentStatus.ALL,
+ ].map((value) => (
+
+
+
+
+ {value !== ExtendedDocumentStatus.ALL && (
+
+ {Math.min(stats[value], 99)}
+ {stats[value] > 99 && '+'}
+
+ )}
+
+
+ ))}
+
+
+
+ {team &&
}
+
+
+
+
+
+
+ {results.count > 0 && (
+
+ )}
+ {results.count === 0 && }
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
index 56c112d75..14370cff8 100644
--- a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
@@ -1,5 +1,7 @@
import { useRouter } from 'next/navigation';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
+import type { Team } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -16,18 +18,21 @@ type DuplicateDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
+ team?: Pick;
};
export const DuplicateDocumentDialog = ({
id,
open,
onOpenChange,
+ team,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
+ teamId: team?.id,
});
const documentData = document?.documentData
@@ -37,10 +42,12 @@ export const DuplicateDocumentDialog = ({
}
: undefined;
+ const documentsPath = formatDocumentsPath(team?.url);
+
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
- router.push(`/documents/${newId}`);
+ router.push(`${documentsPath}/${newId}`);
toast({
title: 'Document Duplicated',
@@ -54,7 +61,7 @@ export const DuplicateDocumentDialog = ({
const onDuplicate = async () => {
try {
- await duplicateDocument({ id });
+ await duplicateDocument({ id, teamId: team?.id });
} catch {
toast({
title: 'Something went wrong',
diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx
index 5780df1dc..b67ed6f02 100644
--- a/apps/web/src/app/(dashboard)/documents/page.tsx
+++ b/apps/web/src/app/(dashboard)/documents/page.tsx
@@ -1,119 +1,16 @@
import type { Metadata } from 'next';
-import Link from 'next/link';
-import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
-import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
-import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
-import { getStats } from '@documenso/lib/server-only/document/get-stats';
-import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
-import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
-import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
-
-import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
-import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
-import { DocumentStatus } from '~/components/formatter/document-status';
-
-import { DocumentsDataTable } from './data-table';
-import { EmptyDocumentState } from './empty-state';
-import { UploadDocument } from './upload-document';
+import type { DocumentsPageViewProps } from './documents-page-view';
+import DocumentsPageView from './documents-page-view';
export type DocumentsPageProps = {
- searchParams?: {
- status?: ExtendedDocumentStatus;
- period?: PeriodSelectorValue;
- page?: string;
- perPage?: string;
- };
+ searchParams?: DocumentsPageViewProps['searchParams'];
};
export const metadata: Metadata = {
title: 'Documents',
};
-export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
- const { user } = await getRequiredServerComponentSession();
- const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
- const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
- const page = Number(searchParams.page) || 1;
- const perPage = Number(searchParams.perPage) || 20;
-
- const stats = await getStats({
- user,
- period,
- });
-
- const results = await findDocuments({
- userId: user.id,
- status,
- orderBy: {
- column: 'createdAt',
- direction: 'desc',
- },
- page,
- perPage,
- period,
- });
-
- const getTabHref = (value: typeof status) => {
- const params = new URLSearchParams(searchParams);
-
- params.set('status', value);
-
- if (params.has('page')) {
- params.delete('page');
- }
-
- return `/documents?${params.toString()}`;
- };
-
- return (
-
-
-
-
-
Documents
-
-
-
-
- {[
- ExtendedDocumentStatus.INBOX,
- ExtendedDocumentStatus.PENDING,
- ExtendedDocumentStatus.COMPLETED,
- ExtendedDocumentStatus.DRAFT,
- ExtendedDocumentStatus.ALL,
- ].map((value) => (
-
-
-
-
- {value !== ExtendedDocumentStatus.ALL && (
-
- {Math.min(stats[value], 99)}
- {stats[value] > 99 && '+'}
-
- )}
-
-
- ))}
-
-
-
-
-
-
-
-
- {results.count > 0 && }
- {results.count === 0 && }
-
-
- );
+export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
+ return ;
}
diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
index 444bd1db0..ed91620dc 100644
--- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
@@ -13,6 +13,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
+import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -21,9 +22,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type UploadDocumentProps = {
className?: string;
+ team?: {
+ id: number;
+ url: string;
+ };
};
-export const UploadDocument = ({ className }: UploadDocumentProps) => {
+export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
const router = useRouter();
const analytics = useAnalytics();
@@ -39,13 +44,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
- return 'You have reached your document limit.';
+ return team
+ ? 'Document upload disabled due to unpaid invoices'
+ : 'You have reached your document limit.';
}
if (!session?.user.emailVerified) {
return 'Verify your email to upload documents.';
}
- }, [remaining.documents, session?.user.emailVerified]);
+ }, [remaining.documents, session?.user.emailVerified, team]);
const onFileDrop = async (file: File) => {
try {
@@ -61,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { id } = await createDocument({
title: file.name,
documentDataId,
+ teamId: team?.id,
});
toast({
@@ -75,7 +83,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
- router.push(`/documents/${id}`);
+ router.push(`${formatDocumentsPath(team?.url)}/${id}`);
} catch (error) {
console.error(error);
@@ -117,11 +125,13 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
/>
- {remaining.documents > 0 && Number.isFinite(remaining.documents) && (
-
- {remaining.documents} of {quota.documents} documents remaining this month.
-
- )}
+ {team?.id === undefined &&
+ remaining.documents > 0 &&
+ Number.isFinite(remaining.documents) && (
+
+ {remaining.documents} of {quota.documents} documents remaining this month.
+
+ )}
{isLoading && (
@@ -130,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
)}
- {remaining.documents === 0 && (
+ {team?.id === undefined && remaining.documents === 0 && (
diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx
index 433aeb18c..99db66c55 100644
--- a/apps/web/src/app/(dashboard)/layout.tsx
+++ b/apps/web/src/app/(dashboard)/layout.tsx
@@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
@@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
redirect('/signin');
}
- const { user } = await getRequiredServerComponentSession();
+ const [{ user }, teams] = await Promise.all([
+ getRequiredServerComponentSession(),
+ getTeams({ userId: session.user.id }),
+ ]);
return (
{!user.emailVerified && }
-
+
+
{children}
diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx
index 8fd78cae3..9ed6a2515 100644
--- a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx
+++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx
@@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
-export const BillingPortalButton = () => {
+export type BillingPortalButtonProps = {
+ buttonProps?: React.ComponentProps;
+};
+
+export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
@@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
};
return (
- handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
+ handleFetchPortalUrl()}
+ loading={isFetchingPortalUrl}
+ >
Manage Subscription
);
diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx
index e226a7e39..cee2aa2f1 100644
--- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx
@@ -5,8 +5,9 @@ import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
-import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
+import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
+import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { type Stripe } from '@documenso/lib/server-only/stripe';
@@ -36,23 +37,23 @@ export default async function BillingSettingsPage() {
user = await getStripeCustomerByUser(user).then((result) => result.user);
}
- const [subscriptions, prices, individualPrices] = await Promise.all([
+ const [subscriptions, prices, communityPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
- getPricesByInterval({ type: 'individual' }),
- getPricesByType('individual'),
+ getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
+ getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
]);
- const individualPriceIds = individualPrices.map(({ id }) => id);
+ const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
- const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
- individualPriceIds.includes(priceId),
+ const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
+ communityPlanPriceIds.includes(priceId),
);
const subscription =
- individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
- individualUserSubscriptions[0];
+ communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
+ communityPlanUserSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx
index 60f7da49c..2890eb5d5 100644
--- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { ProfileForm } from '~/components/forms/profile';
export const metadata: Metadata = {
@@ -13,11 +14,7 @@ export default async function ProfileSettingsPage() {
return (
-
Profile
-
-
Here you can edit your personal details.
-
-
+
diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx
index 4e0a40838..f46784aed 100644
--- a/apps/web/src/app/(dashboard)/settings/security/page.tsx
+++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx
@@ -6,6 +6,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
import { PasswordForm } from '~/components/forms/password';
@@ -19,13 +20,10 @@ export default async function SecuritySettingsPage() {
return (
-
Security
-
-
- Here you can manage your password and security settings.
-
-
-
+
{user.identityProvider === 'DOCUMENSO' ? (
diff --git a/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx
new file mode 100644
index 000000000..8aa81653d
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type AcceptTeamInvitationButtonProps = {
+ teamId: number;
+};
+
+export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
+ const { toast } = useToast();
+
+ const {
+ mutateAsync: acceptTeamInvitation,
+ isLoading,
+ isSuccess,
+ } = trpc.team.acceptTeamInvitation.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Accepted team invitation',
+ duration: 5000,
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ variant: 'destructive',
+ duration: 10000,
+ description: 'Unable to join this team at this time.',
+ });
+ },
+ });
+
+ return (
+
acceptTeamInvitation({ teamId })}
+ loading={isLoading}
+ disabled={isLoading || isSuccess}
+ >
+ Accept
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/teams/page.tsx b/apps/web/src/app/(dashboard)/settings/teams/page.tsx
new file mode 100644
index 000000000..1a3d90b66
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/teams/page.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { AnimatePresence } from 'framer-motion';
+
+import { trpc } from '@documenso/trpc/react';
+import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+import { CreateTeamDialog } from '~/components/(teams)/dialogs/create-team-dialog';
+import { UserSettingsTeamsPageDataTable } from '~/components/(teams)/tables/user-settings-teams-page-data-table';
+
+import { TeamEmailUsage } from './team-email-usage';
+import { TeamInvitations } from './team-invitations';
+
+export default function TeamsSettingsPage() {
+ const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {teamEmail && (
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
new file mode 100644
index 000000000..56a7b110a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import { useState } from 'react';
+
+import type { TeamEmail } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type TeamEmailUsageProps = {
+ teamEmail: TeamEmail & { team: { name: string; url: string } };
+};
+
+export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
+ trpc.team.deleteTeamEmail.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'You have successfully revoked access.',
+ duration: 5000,
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
+ });
+ },
+ });
+
+ return (
+
+
+
Team Email
+
+
+ Your email is currently being used by team{' '}
+ {teamEmail.team.name} ({teamEmail.team.url}
+ ).
+
+
+ They have permission on your behalf to:
+
+
+ Display your name and email in documents
+ View all documents sent to your account
+
+
+
+
+ !isDeletingTeamEmail && setOpen(value)}>
+
+ Revoke access
+
+
+
+
+ Are you sure?
+
+
+ You are about to revoke access for team{' '}
+ {teamEmail.team.name} ({teamEmail.team.url}) to
+ use your email.
+
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ deleteTeamEmail({ teamId: teamEmail.teamId })}
+ >
+ Revoke
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx
new file mode 100644
index 000000000..aa1be3f3f
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { AnimatePresence } from 'framer-motion';
+import { BellIcon } from 'lucide-react';
+
+import { formatTeamUrl } from '@documenso/lib/utils/teams';
+import { trpc } from '@documenso/trpc/react';
+import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
+import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+
+import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
+
+export const TeamInvitations = () => {
+ const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery();
+
+ return (
+
+ {data && data.length > 0 && !isInitialLoading && (
+
+
+
+
+
+
+ You have {data.length} pending team invitation
+ {data.length > 1 ? 's' : ''}.
+
+
+
+
+
+ View invites
+
+
+
+
+
+ Pending invitations
+
+
+ You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
+
+
+
+
+ {data.map((invitation) => (
+
+
+ {invitation.team.name}
+
+ }
+ secondaryText={formatTeamUrl(invitation.team.url)}
+ rightSideComponent={
+
+ }
+ />
+
+ ))}
+
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
index 7930dcd0e..0e8f822c2 100644
--- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
+++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx
@@ -83,7 +83,7 @@ export const TemplatesDataTable = ({
return (
{remaining.documents === 0 && (
-
+
Document Limit Exceeded!
diff --git a/apps/web/src/app/(signing)/sign/[token]/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/layout.tsx
index cfec41cdf..9db36e8aa 100644
--- a/apps/web/src/app/(signing)/sign/[token]/layout.tsx
+++ b/apps/web/src/app/(signing)/sign/[token]/layout.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
+import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';
@@ -12,10 +14,16 @@ export type SigningLayoutProps = {
export default async function SigningLayout({ children }: SigningLayoutProps) {
const { user, session } = await getServerComponentSession();
+ let teams: GetTeamsResponse = [];
+
+ if (user && session) {
+ teams = await getTeams({ userId: user.id });
+ }
+
return (
- {user &&
}
+ {user &&
}
{children}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
new file mode 100644
index 000000000..b7f610cff
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
@@ -0,0 +1,20 @@
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+
+import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-view';
+
+export type DocumentPageProps = {
+ params: {
+ id: string;
+ teamUrl: string;
+ };
+};
+
+export default async function DocumentPage({ params }: DocumentPageProps) {
+ const { teamUrl } = params;
+
+ const { user } = await getRequiredServerComponentSession();
+ const team = await getTeamByUrl({ userId: user.id, teamUrl });
+
+ return ;
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
new file mode 100644
index 000000000..952aeeeea
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
@@ -0,0 +1,25 @@
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+
+import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view';
+import DocumentsPageView from '~/app/(dashboard)/documents/documents-page-view';
+
+export type TeamsDocumentPageProps = {
+ params: {
+ teamUrl: string;
+ };
+ searchParams?: DocumentsPageViewProps['searchParams'];
+};
+
+export default async function TeamsDocumentPage({
+ params,
+ searchParams = {},
+}: TeamsDocumentPageProps) {
+ const { teamUrl } = params;
+
+ const { user } = await getRequiredServerComponentSession();
+
+ const team = await getTeamByUrl({ userId: user.id, teamUrl });
+
+ return ;
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
new file mode 100644
index 000000000..1e1eb9921
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+
+import { ChevronLeft } from 'lucide-react';
+
+import { AppErrorCode } from '@documenso/lib/errors/app-error';
+import { Button } from '@documenso/ui/primitives/button';
+
+type ErrorProps = {
+ error: Error & { digest?: string };
+};
+
+export default function ErrorPage({ error }: ErrorProps) {
+ const router = useRouter();
+
+ let errorMessage = 'Unknown error';
+ let errorDetails = '';
+
+ if (error.message === AppErrorCode.UNAUTHORIZED) {
+ errorMessage = 'Unauthorized';
+ errorDetails = 'You are not authorized to view this page.';
+ }
+
+ return (
+
+
+
{errorMessage}
+
+
Oops! Something went wrong.
+
+
{errorDetails}
+
+
+ {
+ void router.back();
+ }}
+ >
+
+ Go Back
+
+
+
+ View teams
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
new file mode 100644
index 000000000..3b4f43031
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import { useState } from 'react';
+
+import { AlertTriangle } from 'lucide-react';
+import { match } from 'ts-pattern';
+
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import type { TeamMemberRole } from '@documenso/prisma/client';
+import { type Subscription, SubscriptionStatus } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogTitle,
+} from '@documenso/ui/primitives/dialog';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type LayoutBillingBannerProps = {
+ subscription: Subscription;
+ teamId: number;
+ userRole: TeamMemberRole;
+};
+
+export const LayoutBillingBanner = ({
+ subscription,
+ teamId,
+ userRole,
+}: LayoutBillingBannerProps) => {
+ const { toast } = useToast();
+
+ const [isOpen, setIsOpen] = useState(false);
+
+ const { mutateAsync: createBillingPortal, isLoading } =
+ trpc.team.createBillingPortal.useMutation();
+
+ const handleCreatePortal = async () => {
+ try {
+ const sessionUrl = await createBillingPortal({ teamId });
+
+ window.open(sessionUrl, '_blank');
+
+ setIsOpen(false);
+ } catch (err) {
+ toast({
+ title: 'Something went wrong',
+ description:
+ 'We are unable to proceed to the billing portal at this time. Please try again, or contact support.',
+ variant: 'destructive',
+ duration: 10000,
+ });
+ }
+ };
+
+ if (subscription.status === SubscriptionStatus.ACTIVE) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {match(subscription.status)
+ .with(SubscriptionStatus.PAST_DUE, () => 'Payment overdue')
+ .with(SubscriptionStatus.INACTIVE, () => 'Teams restricted')
+ .exhaustive()}
+
+
+
setIsOpen(true)}
+ size="sm"
+ >
+ Resolve
+
+
+
+
+ !isLoading && setIsOpen(value)}>
+
+ Payment overdue
+
+ {match(subscription.status)
+ .with(SubscriptionStatus.PAST_DUE, () => (
+
+ Your payment for teams is overdue. Please settle the payment to avoid any service
+ disruptions.
+
+ ))
+ .with(SubscriptionStatus.INACTIVE, () => (
+
+ Due to an unpaid invoice, your team has been restricted. Please settle the payment
+ to restore full access to your team.
+
+ ))
+ .otherwise(() => null)}
+
+ {canExecuteTeamAction('MANAGE_BILLING', userRole) && (
+
+
+ Resolve payment
+
+
+ )}
+
+
+ >
+ );
+};
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
new file mode 100644
index 000000000..2883abc21
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+
+import { RedirectType, redirect } from 'next/navigation';
+
+import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
+import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { getTeams } from '@documenso/lib/server-only/team/get-teams';
+import { SubscriptionStatus } from '@documenso/prisma/client';
+
+import { Header } from '~/components/(dashboard)/layout/header';
+import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
+import { NextAuthProvider } from '~/providers/next-auth';
+
+import { LayoutBillingBanner } from './layout-billing-banner';
+
+export type AuthenticatedTeamsLayoutProps = {
+ children: React.ReactNode;
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function AuthenticatedTeamsLayout({
+ children,
+ params,
+}: AuthenticatedTeamsLayoutProps) {
+ const { session, user } = await getServerComponentSession();
+
+ if (!session || !user) {
+ redirect('/signin');
+ }
+
+ const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([
+ getTeams({ userId: user.id }),
+ getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl }),
+ ]);
+
+ if (getTeamPromise.status === 'rejected') {
+ redirect('/documents', RedirectType.replace);
+ }
+
+ const team = getTeamPromise.value;
+ const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
+
+ return (
+
+
+ {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
+
+ )}
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
new file mode 100644
index 000000000..35962e264
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import Link from 'next/link';
+
+import { ChevronLeft } from 'lucide-react';
+
+import { Button } from '@documenso/ui/primitives/button';
+
+export default function NotFound() {
+ return (
+
+
+
404 Team not found
+
+
Oops! Something went wrong.
+
+
+ The team you are looking for may have been removed, renamed or may have never existed.
+
+
+
+
+
+
+ Go Back
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx
new file mode 100644
index 000000000..1d0e87f79
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx
@@ -0,0 +1,84 @@
+import { DateTime } from 'luxon';
+import type Stripe from 'stripe';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { stripe } from '@documenso/lib/server-only/stripe';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+import { TeamBillingInvoicesDataTable } from '~/components/(teams)/tables/team-billing-invoices-data-table';
+import { TeamBillingPortalButton } from '~/components/(teams)/team-billing-portal-button';
+
+export type TeamsSettingsBillingPageProps = {
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
+ const session = await getRequiredServerComponentSession();
+
+ const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl });
+
+ const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
+
+ let teamSubscription: Stripe.Subscription | null = null;
+
+ if (team.subscription) {
+ teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
+ }
+
+ const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
+ if (!subscription) {
+ return 'No payment required';
+ }
+
+ const numberOfSeats = subscription.items.data[0].quantity ?? 0;
+
+ const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member';
+
+ const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
+ 'LLL dd, yyyy',
+ );
+
+ return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`;
+ };
+
+ return (
+
+
+
+
+
+
+
+ Current plan: {teamSubscription ? 'Team' : 'Community Team'}
+
+
+
+ {formatTeamSubscriptionDetails(teamSubscription)}
+
+
+
+ {teamSubscription && (
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
new file mode 100644
index 000000000..fe2ee5aee
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+
+import { notFound } from 'next/navigation';
+
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+
+import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav';
+import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav';
+
+export type TeamSettingsLayoutProps = {
+ children: React.ReactNode;
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamsSettingsLayout({
+ children,
+ params: { teamUrl },
+}: TeamSettingsLayoutProps) {
+ const session = await getRequiredServerComponentSession();
+
+ try {
+ const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
+
+ if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
+ throw new Error(AppErrorCode.UNAUTHORIZED);
+ }
+ } catch (e) {
+ const error = AppError.parseError(e);
+
+ if (error.code === 'P2025') {
+ notFound();
+ }
+
+ throw e;
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx
new file mode 100644
index 000000000..4617b3d48
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx
@@ -0,0 +1,38 @@
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+import { InviteTeamMembersDialog } from '~/components/(teams)/dialogs/invite-team-member-dialog';
+import { TeamsMemberPageDataTable } from '~/components/(teams)/tables/teams-member-page-data-table';
+
+export type TeamsSettingsMembersPageProps = {
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
+ const { teamUrl } = params;
+
+ const session = await getRequiredServerComponentSession();
+
+ const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
new file mode 100644
index 000000000..a86797191
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
@@ -0,0 +1,186 @@
+import { CheckCircle2, Clock } from 'lucide-react';
+import { P, match } from 'ts-pattern';
+
+import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
+import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import { isTokenExpired } from '@documenso/lib/utils/token-verification';
+import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+
+import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
+import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email-dialog';
+import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog';
+import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog';
+import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form';
+
+import { TeamEmailDropdown } from './team-email-dropdown';
+import { TeamTransferStatus } from './team-transfer-status';
+
+export type TeamsSettingsPageProps = {
+ params: {
+ teamUrl: string;
+ };
+};
+
+export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
+ const { teamUrl } = params;
+
+ const session = await getRequiredServerComponentSession();
+
+ const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
+
+ const isTransferVerificationExpired =
+ !team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
+
+ return (
+
+
+
+
+
+
+
+
+ {(team.teamEmail || team.emailVerification) && (
+
+ Team email
+
+
+ You can view documents associated with this email and use this identity when sending
+ documents.
+
+
+
+
+
+
+ {team.teamEmail?.name || team.emailVerification?.name}
+
+ }
+ secondaryText={
+
+ {team.teamEmail?.email || team.emailVerification?.email}
+
+ }
+ />
+
+
+
+ {match({
+ teamEmail: team.teamEmail,
+ emailVerification: team.emailVerification,
+ })
+ .with({ teamEmail: P.not(null) }, () => (
+ <>
+
+ Active
+ >
+ ))
+ .with(
+ {
+ emailVerification: P.when(
+ (emailVerification) =>
+ emailVerification && emailVerification?.expiresAt < new Date(),
+ ),
+ },
+ () => (
+ <>
+
+ Expired
+ >
+ ),
+ )
+ .with({ emailVerification: P.not(null) }, () => (
+ <>
+
+ Awaiting email confirmation
+ >
+ ))
+ .otherwise(() => null)}
+
+
+
+
+
+
+ )}
+
+ {!team.teamEmail && !team.emailVerification && (
+
+
+
Team email
+
+
+
+ {/* Feature not available yet. */}
+ {/* Display this name and email when sending documents */}
+ {/* View documents associated with this email */}
+
+ View documents associated with this email
+
+
+
+
+
+
+ )}
+
+ {team.ownerUserId === session.user.id && (
+ <>
+ {isTransferVerificationExpired && (
+
+
+
Transfer team
+
+
+ Transfer the ownership of the team to another team member.
+
+
+
+
+
+ )}
+
+
+
+
Delete team
+
+
+ This team, and any associated data excluding billing invoices will be permanently
+ deleted.
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx
new file mode 100644
index 000000000..e2c0a0d87
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
+
+import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
+import { trpc } from '@documenso/trpc/react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { UpdateTeamEmailDialog } from '~/components/(teams)/dialogs/update-team-email-dialog';
+
+export type TeamsSettingsPageProps = {
+ team: Awaited>;
+};
+
+export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => {
+ const router = useRouter();
+
+ const { toast } = useToast();
+
+ const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } =
+ trpc.team.resendTeamEmailVerification.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Email verification has been resent',
+ duration: 5000,
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ variant: 'destructive',
+ duration: 10000,
+ description: 'Unable to resend verification at this time. Please try again.',
+ });
+ },
+ });
+
+ const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
+ trpc.team.deleteTeamEmail.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Team email has been removed',
+ duration: 5000,
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ variant: 'destructive',
+ duration: 10000,
+ description: 'Unable to remove team email at this time. Please try again.',
+ });
+ },
+ });
+
+ const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } =
+ trpc.team.deleteTeamEmailVerification.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Email verification has been removed',
+ duration: 5000,
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ variant: 'destructive',
+ duration: 10000,
+ description: 'Unable to remove email verification at this time. Please try again.',
+ });
+ },
+ });
+
+ const onRemove = async () => {
+ if (team.teamEmail) {
+ await deleteTeamEmail({ teamId: team.id });
+ }
+
+ if (team.emailVerification) {
+ await deleteTeamEmailVerification({ teamId: team.id });
+ }
+
+ router.refresh();
+ };
+
+ return (
+
+
+
+
+
+
+ {!team.teamEmail && team.emailVerification && (
+ {
+ e.preventDefault();
+ void resendEmailVerification({ teamId: team.id });
+ }}
+ >
+ {isResendingEmailVerification ? (
+
+ ) : (
+
+ )}
+ Resend verification
+
+ )}
+
+ {team.teamEmail && (
+ e.preventDefault()}>
+
+ Edit
+
+ }
+ />
+ )}
+
+ onRemove()}
+ >
+
+ Remove
+
+
+
+ );
+};
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx
new file mode 100644
index 000000000..cba50966f
--- /dev/null
+++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+import { AnimatePresence } from 'framer-motion';
+
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import { isTokenExpired } from '@documenso/lib/utils/token-verification';
+import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
+import { cn } from '@documenso/ui/lib/utils';
+import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type TeamTransferStatusProps = {
+ className?: string;
+ currentUserTeamRole: TeamMemberRole;
+ teamId: number;
+ transferVerification: TeamTransferVerification | null;
+};
+
+export const TeamTransferStatus = ({
+ className,
+ currentUserTeamRole,
+ teamId,
+ transferVerification,
+}: TeamTransferStatusProps) => {
+ const router = useRouter();
+
+ const { toast } = useToast();
+
+ const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
+
+ const { mutateAsync: deleteTeamTransferRequest, isLoading } =
+ trpc.team.deleteTeamTransferRequest.useMutation({
+ onSuccess: () => {
+ if (!isExpired) {
+ toast({
+ title: 'Success',
+ description: 'The team transfer invitation has been successfully deleted.',
+ duration: 5000,
+ });
+ }
+
+ router.refresh();
+ },
+ onError: () => {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.',
+ });
+ },
+ });
+
+ return (
+
+ {transferVerification && (
+
+
+
+
+ {isExpired ? 'Team transfer request expired' : 'Team transfer in progress'}
+
+
+
+ {isExpired ? (
+
+ The team transfer request to {transferVerification.name} has
+ expired.
+
+ ) : (
+
+
+ A request to transfer the ownership of this team has been sent to{' '}
+
+ {transferVerification.name} ({transferVerification.email})
+
+
+
+
+ If they accept this request, the team will be transferred to their account.
+
+
+ )}
+
+
+
+ {canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && (
+ deleteTeamTransferRequest({ teamId })}
+ loading={isLoading}
+ variant={isExpired ? 'destructive' : 'ghost'}
+ className={cn('ml-auto', {
+ 'hover:bg-transparent hover:text-blue-800': !isExpired,
+ })}
+ >
+ {isExpired ? 'Close' : 'Cancel'}
+
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/app/(unauthenticated)/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/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx
index 0312a96d2..3fe42a4c4 100644
--- a/apps/web/src/components/(dashboard)/common/command-menu.tsx
+++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx
@@ -197,20 +197,22 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
)}
{!currentPage && (
<>
-
+
-
+
-
+
-
- addPage('theme')}>Change theme
+
+ addPage('theme')}>
+ Change theme
+
{searchResults.length > 0 && (
-
+
)}
@@ -231,6 +233,7 @@ const Commands = ({
}) => {
return pages.map((page, idx) => (
push(page.path)}
@@ -255,7 +258,7 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
setTheme(theme.theme)}
- className="mx-2 first:mt-2 last:mb-2"
+ className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
>
{theme.label}
diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
index e04bc2818..2b11c4be2 100644
--- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx
@@ -4,10 +4,11 @@ import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
-import { usePathname } from 'next/navigation';
+import { useParams, usePathname } from 'next/navigation';
import { Search } from 'lucide-react';
+import { getRootHref } from '@documenso/lib/utils/params';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -28,10 +29,13 @@ export type DesktopNavProps = HTMLAttributes;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname();
+ const params = useParams();
const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
+ const rootHref = getRootHref(params, { returnEmptyRootString: true });
+
useEffect(() => {
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
@@ -48,20 +52,24 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
{...props}
>
- {navigationLinks.map(({ href, label }) => (
-
- {label}
-
- ))}
+ {navigationLinks
+ .filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages.
+ .map(({ href, label }) => (
+
+ {label}
+
+ ))}
diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx
index ba35671e6..753f5fb11 100644
--- a/apps/web/src/components/(dashboard)/layout/header.tsx
+++ b/apps/web/src/components/(dashboard)/layout/header.tsx
@@ -1,23 +1,34 @@
'use client';
-import type { HTMLAttributes } from 'react';
-import { useEffect, useState } from 'react';
+import { type HTMLAttributes, useEffect, useState } from 'react';
import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { MenuIcon, SearchIcon } from 'lucide-react';
+
+import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
+import { getRootHref } from '@documenso/lib/utils/params';
import type { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Logo } from '~/components/branding/logo';
+import { CommandMenu } from '../common/command-menu';
import { DesktopNav } from './desktop-nav';
-import { ProfileDropdown } from './profile-dropdown';
+import { MenuSwitcher } from './menu-switcher';
+import { MobileNavigation } from './mobile-navigation';
export type HeaderProps = HTMLAttributes & {
user: User;
+ teams: GetTeamsResponse;
};
-export const Header = ({ className, user, ...props }: HeaderProps) => {
+export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
+ const params = useParams();
+
+ const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
+ const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
@@ -41,8 +52,8 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
>
@@ -50,11 +61,24 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
- {/*
-
- */}
+
+ setIsCommandMenuOpen(true)}>
+
+
+
+ setIsHamburgerMenuOpen(true)}>
+
+
+
+
+
+
diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
new file mode 100644
index 000000000..35a05baf2
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
@@ -0,0 +1,214 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
+import { signOut } from 'next-auth/react';
+
+import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
+import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import type { User } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+
+export type MenuSwitcherProps = {
+ user: User;
+ teams: GetTeamsResponse;
+};
+
+export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => {
+ const pathname = usePathname();
+
+ const isUserAdmin = isAdmin(user);
+
+ const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
+ initialData: initialTeamsData,
+ });
+
+ const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
+
+ const isPathTeamUrl = (teamUrl: string) => {
+ if (!pathname || !pathname.startsWith(`/t/`)) {
+ return false;
+ }
+
+ return pathname.split('/')[2] === teamUrl;
+ };
+
+ const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
+
+ const formatAvatarFallback = (teamName?: string) => {
+ if (teamName !== undefined) {
+ return teamName.slice(0, 1).toUpperCase();
+ }
+
+ return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
+ };
+
+ const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
+ if (!team) {
+ return 'Personal Account';
+ }
+
+ if (team.ownerUserId === user.id) {
+ return 'Owner';
+ }
+
+ return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role];
+ };
+
+ return (
+
+
+
+
+ }
+ />
+
+
+
+
+ {teams ? (
+ <>
+ Personal
+
+
+
+
+ )
+ }
+ />
+
+
+
+
+
+
+
+
Teams
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {teams.map((team) => (
+
+
+
+ )
+ }
+ />
+
+
+ ))}
+ >
+ ) : (
+
+
+ Create team
+
+
+
+ )}
+
+
+
+ {isUserAdmin && (
+
+ Admin panel
+
+ )}
+
+
+ User settings
+
+
+ {selectedTeam &&
+ canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
+
+ Team settings
+
+ )}
+
+
+ signOut({
+ callbackUrl: '/',
+ })
+ }
+ >
+ Sign Out
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(dashboard)/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/layout/mobile-nav.tsx
deleted file mode 100644
index e69de29bb..000000000
diff --git a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx
new file mode 100644
index 000000000..7142de5dc
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import Image from 'next/image';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+
+import { signOut } from 'next-auth/react';
+
+import LogoImage from '@documenso/assets/logo.png';
+import { getRootHref } from '@documenso/lib/utils/params';
+import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
+import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
+
+export type MobileNavigationProps = {
+ isMenuOpen: boolean;
+ onMenuOpenChange?: (_value: boolean) => void;
+};
+
+export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
+ const params = useParams();
+
+ const handleMenuItemClick = () => {
+ onMenuOpenChange?.(false);
+ };
+
+ const rootHref = getRootHref(params, { returnEmptyRootString: true });
+
+ const menuNavigationLinks = [
+ {
+ href: `${rootHref}/documents`,
+ text: 'Documents',
+ },
+ {
+ href: `${rootHref}/templates`,
+ text: 'Templates',
+ },
+ {
+ href: '/settings/teams',
+ text: 'Teams',
+ },
+ {
+ href: '/settings/profile',
+ text: 'Settings',
+ },
+ ].filter(({ text, href }) => text !== 'Templates' || href === '/templates'); // Filter out templates for teams.
+
+ return (
+
+
+
+
+
+
+
+ {menuNavigationLinks.map(({ href, text }) => (
+ handleMenuItemClick()}
+ >
+ {text}
+
+ ))}
+
+
+ signOut({
+ callbackUrl: '/',
+ })
+ }
+ >
+ Sign Out
+
+
+
+
+
+
+
+
+
+ © {new Date().getFullYear()} Documenso, Inc. All rights reserved.
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
deleted file mode 100644
index f2432c071..000000000
--- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-
-import {
- CreditCard,
- FileSpreadsheet,
- Lock,
- LogOut,
- User as LucideUser,
- Monitor,
- Moon,
- Palette,
- Sun,
- UserCog,
-} from 'lucide-react';
-import { signOut } from 'next-auth/react';
-import { useTheme } from 'next-themes';
-import { LuGithub } from 'react-icons/lu';
-
-import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
-import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
-import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
-import type { User } from '@documenso/prisma/client';
-import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
-import { Button } from '@documenso/ui/primitives/button';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuPortal,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from '@documenso/ui/primitives/dropdown-menu';
-
-export type ProfileDropdownProps = {
- user: User;
-};
-
-export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
- const { getFlag } = useFeatureFlags();
- const { theme, setTheme } = useTheme();
- const isUserAdmin = isAdmin(user);
-
- const isBillingEnabled = getFlag('app_billing');
-
- const avatarFallback = user.name
- ? recipientInitials(user.name)
- : user.email.slice(0, 1).toUpperCase();
-
- return (
-
-
-
-
- {avatarFallback}
-
-
-
-
-
- Account
-
- {isUserAdmin && (
- <>
-
-
-
- Admin
-
-
-
-
- >
- )}
-
-
-
-
- Profile
-
-
-
-
-
-
- Security
-
-
-
- {isBillingEnabled && (
-
-
-
- Billing
-
-
- )}
-
-
-
-
-
- Templates
-
-
-
-
-
-
-
- Themes
-
-
-
-
-
- Light
-
-
-
- Dark
-
-
-
- System
-
-
-
-
-
-
-
-
-
- Star on Github
-
-
-
-
-
-
- void signOut({
- callbackUrl: '/',
- })
- }
- >
-
- Sign Out
-
-
-
- );
-};
diff --git a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx b/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx
index caeb780d0..a49e2f284 100644
--- a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx
+++ b/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx
@@ -21,9 +21,9 @@ export const PeriodSelector = () => {
const router = useRouter();
const period = useMemo(() => {
- const p = searchParams?.get('period') ?? '';
+ const p = searchParams?.get('period') ?? 'all';
- return isPeriodSelectorValue(p) ? p : '';
+ return isPeriodSelectorValue(p) ? p : 'all';
}, [searchParams]);
const onPeriodChange = (newPeriod: string) => {
@@ -35,7 +35,7 @@ export const PeriodSelector = () => {
params.set('period', newPeriod);
- if (newPeriod === '') {
+ if (newPeriod === '' || newPeriod === 'all') {
params.delete('period');
}
@@ -49,7 +49,7 @@ export const PeriodSelector = () => {
- All Time
+ All Time
Last 7 days
Last 14 days
Last 30 days
diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
index f4b2aae5e..c7ab61d8a 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx
@@ -1,11 +1,11 @@
'use client';
-import { HTMLAttributes } from 'react';
+import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { CreditCard, Lock, User } from 'lucide-react';
+import { CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+
+
+
+ Teams
+
+
+
{
+ return (
+ <>
+
+
+
{title}
+
+
{subtitle}
+
+
+ {children}
+
+
+
+ >
+ );
+};
diff --git a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
index 28ffc960f..2809cefe5 100644
--- a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
+++ b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx
@@ -1,11 +1,11 @@
'use client';
-import { HTMLAttributes } from 'react';
+import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { CreditCard, Lock, User } from 'lucide-react';
+import { CreditCard, Lock, User, Users } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -38,6 +38,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
+
+
+
+ Teams
+
+
+
;
+
+const ZCreateTeamEmailFormSchema = ZCreateTeamEmailVerificationMutationSchema.pick({
+ name: true,
+ email: true,
+});
+
+type TCreateTeamEmailFormSchema = z.infer;
+
+export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDialogProps) => {
+ const router = useRouter();
+
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZCreateTeamEmailFormSchema),
+ defaultValues: {
+ name: '',
+ email: '',
+ },
+ });
+
+ const { mutateAsync: createTeamEmailVerification, isLoading } =
+ trpc.team.createTeamEmailVerification.useMutation();
+
+ const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
+ try {
+ await createTeamEmailVerification({
+ teamId,
+ name,
+ email,
+ });
+
+ toast({
+ title: 'Success',
+ description: 'We have sent a confirmation email for verification.',
+ duration: 5000,
+ });
+
+ router.refresh();
+
+ setOpen(false);
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.ALREADY_EXISTS) {
+ form.setError('email', {
+ type: 'manual',
+ message: 'This email is already being used by another team.',
+ });
+
+ return;
+ }
+
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to add this email. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild={true}>
+ {trigger ?? (
+
+
+ Add email
+
+ )}
+
+
+
+
+ Add team email
+
+
+ A verification email will be sent to the provided email.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx
new file mode 100644
index 000000000..f7ee8ca51
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx
@@ -0,0 +1,177 @@
+import { useMemo, useState } from 'react';
+
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { AnimatePresence, motion } from 'framer-motion';
+import { Loader, TagIcon } from 'lucide-react';
+
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { Card, CardContent } from '@documenso/ui/primitives/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@documenso/ui/primitives/dialog';
+import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type CreateTeamCheckoutDialogProps = {
+ pendingTeamId: number | null;
+ onClose: () => void;
+} & Omit;
+
+const MotionCard = motion(Card);
+
+export const CreateTeamCheckoutDialog = ({
+ pendingTeamId,
+ onClose,
+ ...props
+}: CreateTeamCheckoutDialogProps) => {
+ const { toast } = useToast();
+
+ const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
+
+ const { data, isLoading } = trpc.team.getTeamPrices.useQuery();
+
+ const { mutateAsync: createCheckout, isLoading: isCreatingCheckout } =
+ trpc.team.createTeamPendingCheckout.useMutation({
+ onSuccess: (checkoutUrl) => {
+ window.open(checkoutUrl, '_blank');
+ onClose();
+ },
+ onError: () =>
+ toast({
+ title: 'Something went wrong',
+ description:
+ 'We were unable to create a checkout session. Please try again, or contact support',
+ variant: 'destructive',
+ }),
+ });
+
+ const selectedPrice = useMemo(() => {
+ if (!data) {
+ return null;
+ }
+
+ return data[interval];
+ }, [data, interval]);
+
+ const handleOnOpenChange = (open: boolean) => {
+ if (pendingTeamId === null) {
+ return;
+ }
+
+ if (!open) {
+ onClose();
+ }
+ };
+
+ if (pendingTeamId === null) {
+ return null;
+ }
+
+ return (
+
+
+
+ Team checkout
+
+
+ Payment is required to finalise the creation of your team.
+
+
+
+ {(isLoading || !data) && (
+
+ {isLoading ? (
+
+ ) : (
+
Something went wrong
+ )}
+
+ )}
+
+ {data && selectedPrice && !isLoading && (
+
+
setInterval(value as 'monthly' | 'yearly')}
+ value={interval}
+ className="mb-4"
+ >
+
+ {[data.monthly, data.yearly].map((price) => (
+
+ {price.friendlyInterval}
+
+ ))}
+
+
+
+
+
+
+ {selectedPrice.interval === 'monthly' ? (
+
+ $50 USD per month
+
+ ) : (
+
+
+ $480 USD per year
+
+
+
+ 20% off
+
+
+ )}
+
+
+
This price includes minimum 5 seats.
+
+
+ Adding and removing seats will adjust your invoice accordingly.
+
+
+
+
+
+
+
+ onClose()}
+ >
+ Cancel
+
+
+
+ createCheckout({
+ interval: selectedPrice.interval,
+ pendingTeamId,
+ })
+ }
+ >
+ Checkout
+
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx
new file mode 100644
index 000000000..283fd8dad
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx
@@ -0,0 +1,223 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { useForm } from 'react-hook-form';
+import type { z } from 'zod';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { trpc } from '@documenso/trpc/react';
+import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type CreateTeamDialogProps = {
+ trigger?: React.ReactNode;
+} & Omit;
+
+const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
+ teamName: true,
+ teamUrl: true,
+});
+
+type TCreateTeamFormSchema = z.infer;
+
+export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) => {
+ const { toast } = useToast();
+
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const [open, setOpen] = useState(false);
+
+ const actionSearchParam = searchParams?.get('action');
+
+ const form = useForm({
+ resolver: zodResolver(ZCreateTeamFormSchema),
+ defaultValues: {
+ teamName: '',
+ teamUrl: '',
+ },
+ });
+
+ const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
+
+ const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => {
+ try {
+ const response = await createTeam({
+ teamName,
+ teamUrl,
+ });
+
+ setOpen(false);
+
+ if (response.paymentRequired) {
+ router.push(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
+ return;
+ }
+
+ toast({
+ title: 'Success',
+ description: 'Your team has been created.',
+ duration: 5000,
+ });
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.ALREADY_EXISTS) {
+ form.setError('teamUrl', {
+ type: 'manual',
+ message: 'This URL is already in use.',
+ });
+
+ return;
+ }
+
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to create a team. Please try again later.',
+ });
+ }
+ };
+
+ const mapTextToUrl = (text: string) => {
+ return text.toLowerCase().replace(/\s+/g, '-');
+ };
+
+ useEffect(() => {
+ if (actionSearchParam === 'add-team') {
+ setOpen(true);
+ updateSearchParams({ action: null });
+ }
+ }, [actionSearchParam, open, setOpen, updateSearchParams]);
+
+ useEffect(() => {
+ form.reset();
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild={true}>
+ {trigger ?? (
+
+ Create team
+
+ )}
+
+
+
+
+ Create team
+
+
+ Create a team to collaborate with your team members.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx
new file mode 100644
index 000000000..99630e57c
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx
@@ -0,0 +1,160 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { AppError } from '@documenso/lib/errors/app-error';
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import type { Toast } from '@documenso/ui/primitives/use-toast';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type DeleteTeamDialogProps = {
+ teamId: number;
+ teamName: string;
+ trigger?: React.ReactNode;
+};
+
+export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialogProps) => {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const deleteMessage = `delete ${teamName}`;
+
+ const ZDeleteTeamFormSchema = z.object({
+ teamName: z.literal(deleteMessage, {
+ errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
+ }),
+ });
+
+ const form = useForm({
+ resolver: zodResolver(ZDeleteTeamFormSchema),
+ defaultValues: {
+ teamName: '',
+ },
+ });
+
+ const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation();
+
+ const onFormSubmit = async () => {
+ try {
+ await deleteTeam({ teamId });
+
+ toast({
+ title: 'Success',
+ description: 'Your team has been successfully deleted.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+
+ router.push('/settings/teams');
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ let toastError: Toast = {
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to delete this team. Please try again later.',
+ };
+
+ if (error.code === 'resource_missing') {
+ toastError = {
+ title: 'Unable to delete team',
+ variant: 'destructive',
+ duration: 15000,
+ description:
+ 'Something went wrong while updating the team billing subscription, please contact support.',
+ };
+ }
+
+ toast(toastError);
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}>
+
+ {trigger ?? Delete team }
+
+
+
+
+ Delete team
+
+
+ Are you sure? This is irreversable.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx
new file mode 100644
index 000000000..7ae8ccf1c
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx
@@ -0,0 +1,107 @@
+'use client';
+
+import { useState } from 'react';
+
+import { trpc } from '@documenso/trpc/react';
+import { Alert } from '@documenso/ui/primitives/alert';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type DeleteTeamMemberDialogProps = {
+ teamId: number;
+ teamName: string;
+ teamMemberId: number;
+ teamMemberName: string;
+ teamMemberEmail: string;
+ trigger?: React.ReactNode;
+};
+
+export const DeleteTeamMemberDialog = ({
+ trigger,
+ teamId,
+ teamName,
+ teamMemberId,
+ teamMemberName,
+ teamMemberEmail,
+}: DeleteTeamMemberDialogProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const { mutateAsync: deleteTeamMembers, isLoading: isDeletingTeamMember } =
+ trpc.team.deleteTeamMembers.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'You have successfully removed this user from the team.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+ },
+ onError: () => {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to remove this user. Please try again later.',
+ });
+ },
+ });
+
+ return (
+ !isDeletingTeamMember && setOpen(value)}>
+
+ {trigger ?? Delete team member }
+
+
+
+
+ Are you sure?
+
+
+ You are about to remove the following user from{' '}
+ {teamName} .
+
+
+
+
+ {teamMemberName}}
+ secondaryText={teamMemberEmail}
+ />
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })}
+ >
+ Delete
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx
new file mode 100644
index 000000000..482142c99
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx
@@ -0,0 +1,244 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { Mail, PlusCircle, Trash } from 'lucide-react';
+import { useFieldArray, useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { TeamMemberRole } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type InviteTeamMembersDialogProps = {
+ currentUserTeamRole: TeamMemberRole;
+ teamId: number;
+ trigger?: React.ReactNode;
+} & Omit;
+
+const ZInviteTeamMembersFormSchema = z
+ .object({
+ invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
+ })
+ .refine(
+ (schema) => {
+ const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase());
+
+ return new Set(emails).size === emails.length;
+ },
+ // Dirty hack to handle errors when .root is populated for an array type
+ { message: 'Members must have unique emails', path: ['members__root'] },
+ );
+
+type TInviteTeamMembersFormSchema = z.infer;
+
+export const InviteTeamMembersDialog = ({
+ currentUserTeamRole,
+ teamId,
+ trigger,
+ ...props
+}: InviteTeamMembersDialogProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZInviteTeamMembersFormSchema),
+ defaultValues: {
+ invitations: [
+ {
+ email: '',
+ role: TeamMemberRole.MEMBER,
+ },
+ ],
+ },
+ });
+
+ const {
+ append: appendTeamMemberInvite,
+ fields: teamMemberInvites,
+ remove: removeTeamMemberInvite,
+ } = useFieldArray({
+ control: form.control,
+ name: 'invitations',
+ });
+
+ const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation();
+
+ const onAddTeamMemberInvite = () => {
+ appendTeamMemberInvite({
+ email: '',
+ role: TeamMemberRole.MEMBER,
+ });
+ };
+
+ const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
+ try {
+ await createTeamMemberInvites({
+ teamId,
+ invitations,
+ });
+
+ toast({
+ title: 'Success',
+ description: 'Team invitations have been sent.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+ } catch {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to invite team members. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild>
+ {trigger ?? Invite member }
+
+
+
+
+ Invite team members
+
+
+ An email containing an invitation will be sent to each member.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx
new file mode 100644
index 000000000..27384d680
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import { useState } from 'react';
+
+import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import type { TeamMemberRole } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { Alert } from '@documenso/ui/primitives/alert';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type LeaveTeamDialogProps = {
+ teamId: number;
+ teamName: string;
+ role: TeamMemberRole;
+ trigger?: React.ReactNode;
+};
+
+export const LeaveTeamDialog = ({ trigger, teamId, teamName, role }: LeaveTeamDialogProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const { mutateAsync: leaveTeam, isLoading: isLeavingTeam } = trpc.team.leaveTeam.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'You have successfully left this team.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+ },
+ onError: () => {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to leave this team. Please try again later.',
+ });
+ },
+ });
+
+ return (
+ !isLeavingTeam && setOpen(value)}>
+
+ {trigger ?? Leave team }
+
+
+
+
+ Are you sure?
+
+
+ You are about to leave the following team.
+
+
+
+
+
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ leaveTeam({ teamId })}
+ >
+ Leave
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx
new file mode 100644
index 000000000..e5dd8ca17
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx
@@ -0,0 +1,293 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Loader } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { trpc } from '@documenso/trpc/react';
+import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type TransferTeamDialogProps = {
+ teamId: number;
+ teamName: string;
+ ownerUserId: number;
+ trigger?: React.ReactNode;
+};
+
+export const TransferTeamDialog = ({
+ trigger,
+ teamId,
+ teamName,
+ ownerUserId,
+}: TransferTeamDialogProps) => {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const { mutateAsync: requestTeamOwnershipTransfer } =
+ trpc.team.requestTeamOwnershipTransfer.useMutation();
+
+ const {
+ data,
+ refetch: refetchTeamMembers,
+ isLoading: loadingTeamMembers,
+ isLoadingError: loadingTeamMembersError,
+ } = trpc.team.getTeamMembers.useQuery({
+ teamId,
+ });
+
+ const confirmTransferMessage = `transfer ${teamName}`;
+
+ const ZTransferTeamFormSchema = z.object({
+ teamName: z.literal(confirmTransferMessage, {
+ errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }),
+ }),
+ newOwnerUserId: z.string(),
+ clearPaymentMethods: z.boolean(),
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(ZTransferTeamFormSchema),
+ defaultValues: {
+ teamName: '',
+ clearPaymentMethods: false,
+ },
+ });
+
+ const onFormSubmit = async ({
+ newOwnerUserId,
+ clearPaymentMethods,
+ }: z.infer) => {
+ try {
+ await requestTeamOwnershipTransfer({
+ teamId,
+ newOwnerUserId: Number.parseInt(newOwnerUserId),
+ clearPaymentMethods,
+ });
+
+ router.refresh();
+
+ toast({
+ title: 'Success',
+ description: 'An email requesting the transfer of this team has been sent.',
+ duration: 5000,
+ });
+
+ setOpen(false);
+ } catch (err) {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ duration: 10000,
+ description:
+ 'We encountered an unknown error while attempting to request a transfer of this team. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ useEffect(() => {
+ if (open && loadingTeamMembersError) {
+ void refetchTeamMembers();
+ }
+ }, [open, loadingTeamMembersError, refetchTeamMembers]);
+
+ const teamMembers = data
+ ? data.filter((teamMember) => teamMember.userId !== ownerUserId)
+ : undefined;
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}>
+
+ {trigger ?? (
+
+ Transfer team
+
+ )}
+
+
+ {teamMembers && teamMembers.length > 0 ? (
+
+
+ Transfer team
+
+
+ Transfer ownership of this team to a selected team member.
+
+
+
+
+
+
+ ) : (
+
+ {loadingTeamMembers ? (
+
+ ) : (
+
+ {loadingTeamMembersError
+ ? 'An error occurred while loading team members. Please try again later.'
+ : 'You must have at least one other team member to transfer ownership.'}
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx
new file mode 100644
index 000000000..c6ab8890a
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import type { TeamEmail } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type UpdateTeamEmailDialogProps = {
+ teamEmail: TeamEmail;
+ trigger?: React.ReactNode;
+} & Omit;
+
+const ZUpdateTeamEmailFormSchema = z.object({
+ name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
+});
+
+type TUpdateTeamEmailFormSchema = z.infer;
+
+export const UpdateTeamEmailDialog = ({
+ teamEmail,
+ trigger,
+ ...props
+}: UpdateTeamEmailDialogProps) => {
+ const router = useRouter();
+
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZUpdateTeamEmailFormSchema),
+ defaultValues: {
+ name: teamEmail.name,
+ },
+ });
+
+ const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation();
+
+ const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
+ try {
+ await updateTeamEmail({
+ teamId: teamEmail.teamId,
+ data: {
+ name,
+ },
+ });
+
+ toast({
+ title: 'Success',
+ description: 'Team email was updated.',
+ duration: 5000,
+ });
+
+ router.refresh();
+
+ setOpen(false);
+ } catch (err) {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting update the team email. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ form.reset();
+ }
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild>
+ {trigger ?? (
+
+ Update team email
+
+ )}
+
+
+
+
+ Update team email
+
+
+ To change the email you must remove and add a new email address.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx
new file mode 100644
index 000000000..cc8ea675f
--- /dev/null
+++ b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx
@@ -0,0 +1,185 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import type * as DialogPrimitive from '@radix-ui/react-dialog';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
+import { TeamMemberRole } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type UpdateTeamMemberDialogProps = {
+ currentUserTeamRole: TeamMemberRole;
+ trigger?: React.ReactNode;
+ teamId: number;
+ teamMemberId: number;
+ teamMemberName: string;
+ teamMemberRole: TeamMemberRole;
+} & Omit;
+
+const ZUpdateTeamMemberFormSchema = z.object({
+ role: z.nativeEnum(TeamMemberRole),
+});
+
+type ZUpdateTeamMemberSchema = z.infer;
+
+export const UpdateTeamMemberDialog = ({
+ currentUserTeamRole,
+ trigger,
+ teamId,
+ teamMemberId,
+ teamMemberName,
+ teamMemberRole,
+ ...props
+}: UpdateTeamMemberDialogProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZUpdateTeamMemberFormSchema),
+ defaultValues: {
+ role: teamMemberRole,
+ },
+ });
+
+ const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation();
+
+ const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
+ try {
+ await updateTeamMember({
+ teamId,
+ teamMemberId,
+ data: {
+ role,
+ },
+ });
+
+ toast({
+ title: 'Success',
+ description: `You have updated ${teamMemberName}.`,
+ duration: 5000,
+ });
+
+ setOpen(false);
+ } catch {
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to update this team member. Please try again later.',
+ });
+ }
+ };
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ form.reset();
+
+ if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) {
+ setOpen(false);
+
+ toast({
+ title: 'You cannot modify a team member who has a higher role than you.',
+ variant: 'destructive',
+ });
+ }
+ }, [open, currentUserTeamRole, teamMemberRole, form, toast]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild>
+ {trigger ?? Update team member }
+
+
+
+
+ Update team member
+
+
+ You are currently updating {teamMemberName}.
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/forms/update-team-form.tsx b/apps/web/src/components/(teams)/forms/update-team-form.tsx
new file mode 100644
index 000000000..142914b8c
--- /dev/null
+++ b/apps/web/src/components/(teams)/forms/update-team-form.tsx
@@ -0,0 +1,173 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { AnimatePresence, motion } from 'framer-motion';
+import { useForm } from 'react-hook-form';
+import type { z } from 'zod';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { trpc } from '@documenso/trpc/react';
+import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
+import { Button } from '@documenso/ui/primitives/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@documenso/ui/primitives/form/form';
+import { Input } from '@documenso/ui/primitives/input';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type UpdateTeamDialogProps = {
+ teamId: number;
+ teamName: string;
+ teamUrl: string;
+};
+
+const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
+ name: true,
+ url: true,
+});
+
+type TUpdateTeamFormSchema = z.infer;
+
+export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
+ const router = useRouter();
+
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(ZUpdateTeamFormSchema),
+ defaultValues: {
+ name: teamName,
+ url: teamUrl,
+ },
+ });
+
+ const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
+
+ const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
+ try {
+ await updateTeam({
+ data: {
+ name,
+ url,
+ },
+ teamId,
+ });
+
+ toast({
+ title: 'Success',
+ description: 'Your team has been successfully updated.',
+ duration: 5000,
+ });
+
+ form.reset({
+ name,
+ url,
+ });
+
+ if (url !== teamUrl) {
+ router.push(`${WEBAPP_BASE_URL}/t/${url}/settings`);
+ }
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.ALREADY_EXISTS) {
+ form.setError('url', {
+ type: 'manual',
+ message: 'This URL is already in use.',
+ });
+
+ return;
+ }
+
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to update your team. Please try again later.',
+ });
+ }
+ };
+
+ return (
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx
new file mode 100644
index 000000000..be68f6c03
--- /dev/null
+++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import type { HTMLAttributes } from 'react';
+
+import Link from 'next/link';
+import { useParams, usePathname } from 'next/navigation';
+
+import { CreditCard, Settings, Users } from 'lucide-react';
+
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+
+export type DesktopNavProps = HTMLAttributes;
+
+export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
+ const pathname = usePathname();
+ const params = useParams();
+
+ const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
+
+ const settingsPath = `/t/${teamUrl}/settings`;
+ const membersPath = `/t/${teamUrl}/settings/members`;
+ const billingPath = `/t/${teamUrl}/settings/billing`;
+
+ return (
+
+
+
+
+ General
+
+
+
+
+
+
+ Members
+
+
+
+ {IS_BILLING_ENABLED && (
+
+
+
+ Billing
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx
new file mode 100644
index 000000000..de01ca9bf
--- /dev/null
+++ b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import type { HTMLAttributes } from 'react';
+
+import Link from 'next/link';
+import { useParams, usePathname } from 'next/navigation';
+
+import { CreditCard, Key, User } from 'lucide-react';
+
+import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+
+export type MobileNavProps = HTMLAttributes;
+
+export const MobileNav = ({ className, ...props }: MobileNavProps) => {
+ const pathname = usePathname();
+ const params = useParams();
+
+ const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
+
+ const settingsPath = `/t/${teamUrl}/settings`;
+ const membersPath = `/t/${teamUrl}/settings/members`;
+ const billingPath = `/t/${teamUrl}/settings/billing`;
+
+ return (
+
+
+
+
+ General
+
+
+
+
+
+
+ Members
+
+
+
+ {IS_BILLING_ENABLED && (
+
+
+
+ Billing
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx
new file mode 100644
index 000000000..0dd4bcf4c
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
+import { trpc } from '@documenso/trpc/react';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { Button } from '@documenso/ui/primitives/button';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+import { LeaveTeamDialog } from '../dialogs/leave-team-dialog';
+
+export const CurrentUserTeamsDataTable = () => {
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
+ Object.fromEntries(searchParams ?? []),
+ );
+
+ const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeams.useQuery(
+ {
+ term: parsedSearchParams.query,
+ page: parsedSearchParams.page,
+ perPage: parsedSearchParams.perPage,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const onPaginationChange = (page: number, perPage: number) => {
+ updateSearchParams({
+ page,
+ perPage,
+ });
+ };
+
+ const results = data ?? {
+ data: [],
+ perPage: 10,
+ currentPage: 1,
+ totalPages: 1,
+ };
+
+ return (
+ (
+
+ {row.original.name}
+ }
+ secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
+ />
+
+ ),
+ },
+ {
+ header: 'Role',
+ accessorKey: 'role',
+ cell: ({ row }) =>
+ row.original.ownerUserId === row.original.currentTeamMember.userId
+ ? 'Owner'
+ : TEAM_MEMBER_ROLE_MAP[row.original.currentTeamMember.role],
+ },
+ {
+ header: 'Member Since',
+ accessorKey: 'createdAt',
+ cell: ({ row }) => ,
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => (
+
+ {canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && (
+
+ Manage
+
+ )}
+
+ e.preventDefault()}
+ >
+ Leave
+
+ }
+ />
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ onPaginationChange={onPaginationChange}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx
new file mode 100644
index 000000000..64a58375c
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx
@@ -0,0 +1,53 @@
+import { trpc } from '@documenso/trpc/react';
+import { cn } from '@documenso/ui/lib/utils';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type PendingUserTeamsDataTableActionsProps = {
+ className?: string;
+ pendingTeamId: number;
+ onPayClick: (pendingTeamId: number) => void;
+};
+
+export const PendingUserTeamsDataTableActions = ({
+ className,
+ pendingTeamId,
+ onPayClick,
+}: PendingUserTeamsDataTableActionsProps) => {
+ const { toast } = useToast();
+
+ const { mutateAsync: deleteTeamPending, isLoading: deletingTeam } =
+ trpc.team.deleteTeamPending.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Pending team deleted.',
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ description:
+ 'We encountered an unknown error while attempting to delete the pending team. Please try again later.',
+ duration: 10000,
+ variant: 'destructive',
+ });
+ },
+ });
+
+ return (
+
+ onPayClick(pendingTeamId)}>
+ Pay
+
+
+ deleteTeamPending({ pendingTeamId: pendingTeamId })}
+ >
+ Remove
+
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx
new file mode 100644
index 000000000..84d4e38df
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx
@@ -0,0 +1,145 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { useSearchParams } from 'next/navigation';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { trpc } from '@documenso/trpc/react';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+import { CreateTeamCheckoutDialog } from '../dialogs/create-team-checkout-dialog';
+import { PendingUserTeamsDataTableActions } from './pending-user-teams-data-table-actions';
+
+export const PendingUserTeamsDataTable = () => {
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
+ Object.fromEntries(searchParams ?? []),
+ );
+
+ const [checkoutPendingTeamId, setCheckoutPendingTeamId] = useState(null);
+
+ const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery(
+ {
+ term: parsedSearchParams.query,
+ page: parsedSearchParams.page,
+ perPage: parsedSearchParams.perPage,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const onPaginationChange = (page: number, perPage: number) => {
+ updateSearchParams({
+ page,
+ perPage,
+ });
+ };
+
+ const results = data ?? {
+ data: [],
+ perPage: 10,
+ currentPage: 1,
+ totalPages: 1,
+ };
+
+ useEffect(() => {
+ const searchParamCheckout = searchParams?.get('checkout');
+
+ if (searchParamCheckout && !isNaN(parseInt(searchParamCheckout))) {
+ setCheckoutPendingTeamId(parseInt(searchParamCheckout));
+ updateSearchParams({ checkout: null });
+ }
+ }, [searchParams, updateSearchParams]);
+
+ return (
+ <>
+ (
+ {row.original.name}
+ }
+ secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
+ />
+ ),
+ },
+ {
+ header: 'Created on',
+ accessorKey: 'createdAt',
+ cell: ({ row }) => ,
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => (
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ onPaginationChange={onPaginationChange}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+
+ setCheckoutPendingTeamId(null)}
+ />
+ >
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx
new file mode 100644
index 000000000..a860ac6d9
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx
@@ -0,0 +1,152 @@
+'use client';
+
+import Link from 'next/link';
+
+import { File } from 'lucide-react';
+import { DateTime } from 'luxon';
+
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+
+export type TeamBillingInvoicesDataTableProps = {
+ teamId: number;
+};
+
+export const TeamBillingInvoicesDataTable = ({ teamId }: TeamBillingInvoicesDataTableProps) => {
+ const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamInvoices.useQuery(
+ {
+ teamId,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const formatCurrency = (currency: string, amount: number) => {
+ const formatter = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ });
+
+ return formatter.format(amount);
+ };
+
+ const results = {
+ data: data?.data ?? [],
+ perPage: 100,
+ currentPage: 1,
+ totalPages: 1,
+ };
+
+ return (
+ (
+
+
+
+
+
+ {DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')}
+
+
+ {row.original.quantity} {row.original.quantity > 1 ? 'Seats' : 'Seat'}
+
+
+
+ ),
+ },
+ {
+ header: 'Status',
+ accessorKey: 'status',
+ cell: ({ row }) => {
+ const { status, paid } = row.original;
+
+ if (!status) {
+ return paid ? 'Paid' : 'Unpaid';
+ }
+
+ return status.charAt(0).toUpperCase() + status.slice(1);
+ },
+ },
+ {
+ header: 'Amount',
+ accessorKey: 'total',
+ cell: ({ row }) => formatCurrency(row.original.currency, row.original.total / 100),
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => (
+
+
+
+ View
+
+
+
+
+
+ Download
+
+
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx
new file mode 100644
index 000000000..f0e3580e3
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx
@@ -0,0 +1,203 @@
+'use client';
+
+import { useSearchParams } from 'next/navigation';
+
+import { History, MoreHorizontal, Trash2 } from 'lucide-react';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { trpc } from '@documenso/trpc/react';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+export type TeamMemberInvitesDataTableProps = {
+ teamId: number;
+};
+
+export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTableProps) => {
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const { toast } = useToast();
+
+ const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
+ Object.fromEntries(searchParams ?? []),
+ );
+
+ const { data, isLoading, isInitialLoading, isLoadingError } =
+ trpc.team.findTeamMemberInvites.useQuery(
+ {
+ teamId,
+ term: parsedSearchParams.query,
+ page: parsedSearchParams.page,
+ perPage: parsedSearchParams.perPage,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const { mutateAsync: resendTeamMemberInvitation } =
+ trpc.team.resendTeamMemberInvitation.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Invitation has been resent',
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ description: 'Unable to resend invitation. Please try again.',
+ variant: 'destructive',
+ });
+ },
+ });
+
+ const { mutateAsync: deleteTeamMemberInvitations } =
+ trpc.team.deleteTeamMemberInvitations.useMutation({
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Invitation has been deleted',
+ });
+ },
+ onError: () => {
+ toast({
+ title: 'Something went wrong',
+ description: 'Unable to delete invitation. Please try again.',
+ variant: 'destructive',
+ });
+ },
+ });
+
+ const onPaginationChange = (page: number, perPage: number) => {
+ updateSearchParams({
+ page,
+ perPage,
+ });
+ };
+
+ const results = data ?? {
+ data: [],
+ perPage: 10,
+ currentPage: 1,
+ totalPages: 1,
+ };
+
+ return (
+ {
+ return (
+ {row.original.email}
+ }
+ />
+ );
+ },
+ },
+ {
+ header: 'Role',
+ accessorKey: 'role',
+ cell: ({ row }) => TEAM_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role,
+ },
+ {
+ header: 'Invited At',
+ accessorKey: 'createdAt',
+ cell: ({ row }) => ,
+ },
+ {
+ header: 'Actions',
+ cell: ({ row }) => (
+
+
+
+
+
+
+ Actions
+
+
+ resendTeamMemberInvitation({
+ teamId,
+ invitationId: row.original.id,
+ })
+ }
+ >
+
+ Resend
+
+
+
+ deleteTeamMemberInvitations({
+ teamId,
+ invitationIds: [row.original.id],
+ })
+ }
+ >
+
+ Remove
+
+
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ onPaginationChange={onPaginationChange}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/team-members-data-table.tsx b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx
new file mode 100644
index 000000000..3002ecbb0
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx
@@ -0,0 +1,209 @@
+'use client';
+
+import { useSearchParams } from 'next/navigation';
+
+import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
+
+import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
+import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
+import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
+import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
+import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
+import type { TeamMemberRole } from '@documenso/prisma/client';
+import { trpc } from '@documenso/trpc/react';
+import { AvatarWithText } from '@documenso/ui/primitives/avatar';
+import { DataTable } from '@documenso/ui/primitives/data-table';
+import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from '@documenso/ui/primitives/dropdown-menu';
+import { Skeleton } from '@documenso/ui/primitives/skeleton';
+import { TableCell } from '@documenso/ui/primitives/table';
+
+import { LocaleDate } from '~/components/formatter/locale-date';
+
+import { DeleteTeamMemberDialog } from '../dialogs/delete-team-member-dialog';
+import { UpdateTeamMemberDialog } from '../dialogs/update-team-member-dialog';
+
+export type TeamMembersDataTableProps = {
+ currentUserTeamRole: TeamMemberRole;
+ teamOwnerUserId: number;
+ teamId: number;
+ teamName: string;
+};
+
+export const TeamMembersDataTable = ({
+ currentUserTeamRole,
+ teamOwnerUserId,
+ teamId,
+ teamName,
+}: TeamMembersDataTableProps) => {
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
+ Object.fromEntries(searchParams ?? []),
+ );
+
+ const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery(
+ {
+ teamId,
+ term: parsedSearchParams.query,
+ page: parsedSearchParams.page,
+ perPage: parsedSearchParams.perPage,
+ },
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ const onPaginationChange = (page: number, perPage: number) => {
+ updateSearchParams({
+ page,
+ perPage,
+ });
+ };
+
+ const results = data ?? {
+ data: [],
+ perPage: 10,
+ currentPage: 1,
+ totalPages: 1,
+ };
+
+ return (
+ {
+ const avatarFallbackText = row.original.user.name
+ ? extractInitials(row.original.user.name)
+ : row.original.user.email.slice(0, 1).toUpperCase();
+
+ return (
+ {row.original.user.name}
+ }
+ secondaryText={row.original.user.email}
+ />
+ );
+ },
+ },
+ {
+ header: 'Role',
+ accessorKey: 'role',
+ cell: ({ row }) =>
+ teamOwnerUserId === row.original.userId
+ ? 'Owner'
+ : TEAM_MEMBER_ROLE_MAP[row.original.role],
+ },
+ {
+ header: 'Member Since',
+ accessorKey: 'createdAt',
+ cell: ({ row }) => ,
+ },
+ {
+ header: 'Actions',
+ cell: ({ row }) => (
+
+
+
+
+
+
+ Actions
+
+ e.preventDefault()}
+ title="Update team member role"
+ >
+
+ Update role
+
+ }
+ />
+
+ e.preventDefault()}
+ disabled={
+ teamOwnerUserId === row.original.userId ||
+ !isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role)
+ }
+ title="Remove team member"
+ >
+
+ Remove
+
+ }
+ />
+
+
+ ),
+ },
+ ]}
+ data={results.data}
+ perPage={results.perPage}
+ currentPage={results.currentPage}
+ totalPages={results.totalPages}
+ onPaginationChange={onPaginationChange}
+ error={{
+ enable: isLoadingError,
+ }}
+ skeleton={{
+ enable: isLoading && isInitialLoading,
+ rows: 3,
+ component: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ }}
+ >
+ {(table) => }
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx
new file mode 100644
index 000000000..316c4373f
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import Link from 'next/link';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
+import type { TeamMemberRole } from '@documenso/prisma/client';
+import { Input } from '@documenso/ui/primitives/input';
+import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
+
+import { TeamMemberInvitesDataTable } from '~/components/(teams)/tables/team-member-invites-data-table';
+import { TeamMembersDataTable } from '~/components/(teams)/tables/team-members-data-table';
+
+export type TeamsMemberPageDataTableProps = {
+ currentUserTeamRole: TeamMemberRole;
+ teamId: number;
+ teamName: string;
+ teamOwnerUserId: number;
+};
+
+export const TeamsMemberPageDataTable = ({
+ currentUserTeamRole,
+ teamId,
+ teamName,
+ teamOwnerUserId,
+}: TeamsMemberPageDataTableProps) => {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
+
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
+
+ const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
+
+ /**
+ * Handle debouncing the search query.
+ */
+ useEffect(() => {
+ if (!pathname) {
+ return;
+ }
+
+ const params = new URLSearchParams(searchParams?.toString());
+
+ params.set('query', debouncedSearchQuery);
+
+ if (debouncedSearchQuery === '') {
+ params.delete('query');
+ }
+
+ router.push(`${pathname}?${params.toString()}`);
+ }, [debouncedSearchQuery, pathname, router, searchParams]);
+
+ return (
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search"
+ />
+
+
+
+
+ All
+
+
+
+ Pending
+
+
+
+
+
+ {currentTab === 'invites' ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx b/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx
new file mode 100644
index 000000000..277421263
--- /dev/null
+++ b/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import Link from 'next/link';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+
+import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
+import { trpc } from '@documenso/trpc/react';
+import { Input } from '@documenso/ui/primitives/input';
+import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
+
+import { CurrentUserTeamsDataTable } from './current-user-teams-data-table';
+import { PendingUserTeamsDataTable } from './pending-user-teams-data-table';
+
+export const UserSettingsTeamsPageDataTable = () => {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
+
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
+
+ const currentTab = searchParams?.get('tab') === 'pending' ? 'pending' : 'active';
+
+ const { data } = trpc.team.findTeamsPending.useQuery(
+ {},
+ {
+ keepPreviousData: true,
+ },
+ );
+
+ /**
+ * Handle debouncing the search query.
+ */
+ useEffect(() => {
+ if (!pathname) {
+ return;
+ }
+
+ const params = new URLSearchParams(searchParams?.toString());
+
+ params.set('query', debouncedSearchQuery);
+
+ if (debouncedSearchQuery === '') {
+ params.delete('query');
+ }
+
+ router.push(`${pathname}?${params.toString()}`);
+ }, [debouncedSearchQuery, pathname, router, searchParams]);
+
+ return (
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search"
+ />
+
+
+
+
+ Active
+
+
+
+
+ Pending
+ {data && data.count > 0 && (
+ {data.count}
+ )}
+
+
+
+
+
+
+ {currentTab === 'pending' ?
:
}
+
+ );
+};
diff --git a/apps/web/src/components/(teams)/team-billing-portal-button.tsx b/apps/web/src/components/(teams)/team-billing-portal-button.tsx
new file mode 100644
index 000000000..808b9b9ba
--- /dev/null
+++ b/apps/web/src/components/(teams)/team-billing-portal-button.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+export type TeamBillingPortalButtonProps = {
+ buttonProps?: React.ComponentProps;
+ teamId: number;
+};
+
+export const TeamBillingPortalButton = ({ buttonProps, teamId }: TeamBillingPortalButtonProps) => {
+ const { toast } = useToast();
+
+ const { mutateAsync: createBillingPortal, isLoading } =
+ trpc.team.createBillingPortal.useMutation();
+
+ const handleCreatePortal = async () => {
+ try {
+ const sessionUrl = await createBillingPortal({ teamId });
+
+ window.open(sessionUrl, '_blank');
+ } catch (err) {
+ toast({
+ title: 'Something went wrong',
+ description:
+ 'We are unable to proceed to the billing portal at this time. Please try again, or contact support.',
+ variant: 'destructive',
+ duration: 10000,
+ });
+ }
+ };
+
+ return (
+ handleCreatePortal()} loading={isLoading}>
+ Manage subscription
+
+ );
+};
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx
index b3e4ea019..b21e9621b 100644
--- a/apps/web/src/components/forms/signin.tsx
+++ b/apps/web/src/components/forms/signin.tsx
@@ -55,10 +55,11 @@ export type TSignInFormSchema = z.infer;
export type SignInFormProps = {
className?: string;
+ initialEmail?: string;
isGoogleSSOEnabled?: boolean;
};
-export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => {
+export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
const { toast } = useToast();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
@@ -69,7 +70,7 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
const form = useForm({
values: {
- email: '',
+ email: initialEmail ?? '',
password: '',
totpCode: '',
backupCode: '',
diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx
index f38ab15d1..430c7ebdf 100644
--- a/apps/web/src/components/forms/signup.tsx
+++ b/apps/web/src/components/forms/signup.tsx
@@ -48,17 +48,18 @@ 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 form = useForm({
values: {
name: '',
- email: '',
+ email: initialEmail ?? '',
password: '',
signature: '',
},
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..aae034c57 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",
@@ -19750,13 +19643,19 @@
"@prisma/client": "5.4.2",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
- "prisma": "5.4.2"
+ "prisma": "5.4.2",
+ "ts-pattern": "^5.0.6"
},
"devDependencies": {
"ts-node": "^10.9.1",
"typescript": "5.2.2"
}
},
+ "packages/prisma/node_modules/ts-pattern": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.6.tgz",
+ "integrity": "sha512-Y+jOjihlFriWzcBjncPCf2/am+Hgz7LtsWs77pWg5vQQKLQj07oNrJryo/wK2G0ndNaoVn2ownFMeoeAuReu3Q=="
+ },
"packages/prisma/node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@@ -19864,7 +19763,7 @@
"@radix-ui/react-checkbox": "^1.0.3",
"@radix-ui/react-collapsible": "^1.0.2",
"@radix-ui/react-context-menu": "^2.1.3",
- "@radix-ui/react-dialog": "^1.0.3",
+ "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-hover-card": "^1.0.5",
"@radix-ui/react-label": "^2.0.1",
@@ -19874,7 +19773,7 @@
"@radix-ui/react-progress": "^1.0.2",
"@radix-ui/react-radio-group": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.0.3",
- "@radix-ui/react-select": "^1.2.1",
+ "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.2",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts
new file mode 100644
index 000000000..f1926fb2a
--- /dev/null
+++ b/packages/app-tests/e2e/fixtures/authentication.ts
@@ -0,0 +1,40 @@
+import type { Page } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+
+type ManualLoginOptions = {
+ page: Page;
+ email?: string;
+ password?: string;
+
+ /**
+ * Where to navigate after login.
+ */
+ redirectPath?: string;
+};
+
+export const manualLogin = async ({
+ page,
+ email = 'example@documenso.com',
+ password = 'password',
+ redirectPath,
+}: ManualLoginOptions) => {
+ await page.goto(`${WEBAPP_BASE_URL}/signin`);
+
+ await page.getByLabel('Email').click();
+ await page.getByLabel('Email').fill(email);
+
+ await page.getByLabel('Password', { exact: true }).fill(password);
+ await page.getByLabel('Password', { exact: true }).press('Enter');
+
+ if (redirectPath) {
+ await page.waitForURL(`${WEBAPP_BASE_URL}/documents`);
+ await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
+ }
+};
+
+export const manualSignout = async ({ page }: ManualLoginOptions) => {
+ await page.getByTestId('menu-switcher').click();
+ await page.getByRole('menuitem', { name: 'Sign Out' }).click();
+ await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
+};
diff --git a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
index 12a099bbf..da95c66f0 100644
--- a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
+++ b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts
@@ -2,6 +2,8 @@ import { expect, test } from '@playwright/test';
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
+import { manualLogin, manualSignout } from './fixtures/authentication';
+
test.describe.configure({ mode: 'serial' });
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
@@ -19,17 +21,11 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
for (const recipient of recipients) {
- await page.goto('/signin');
-
- await page.getByLabel('Email').fill(recipient.email);
- await page.getByLabel('Password', { exact: true }).fill(recipient.password);
- await page.getByRole('button', { name: 'Sign In' }).click();
+ await page.waitForURL('/signin');
+ await manualLogin({ page, email: recipient.email, password: recipient.password });
await page.waitForURL('/documents');
@@ -38,10 +34,7 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
}
});
@@ -74,13 +67,10 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
- // signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
for (const recipient of recipients) {
+ await page.waitForURL('/signin');
await page.goto('/signin');
// sign in
@@ -96,11 +86,7 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
await page.goto('/documents');
-
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
}
});
@@ -115,11 +101,7 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
await page.goto('/signin');
- // sign in
- await page.getByLabel('Email').fill(sender.email);
- await page.getByLabel('Password', { exact: true }).fill(sender.password);
- await page.getByRole('button', { name: 'Sign In' }).click();
-
+ await manualLogin({ page, email: sender.email, password: sender.password });
await page.waitForURL('/documents');
// open actions menu
@@ -133,19 +115,12 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
for (const recipient of recipients) {
- await page.goto('/signin');
-
- // sign in
- await page.getByLabel('Email').fill(recipient.email);
- await page.getByLabel('Password', { exact: true }).fill(recipient.password);
- await page.getByRole('button', { name: 'Sign In' }).click();
+ await page.waitForURL('/signin');
+ await manualLogin({ page, email: recipient.email, password: recipient.password });
await page.waitForURL('/documents');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
@@ -154,11 +129,9 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
+ await page.waitForURL('/documents');
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
-
- await page.waitForURL('/signin');
+ await manualSignout({ page });
}
});
@@ -167,13 +140,7 @@ test('[PR-711]: deleting a draft document should remove it without additional pr
}) => {
const [sender] = TEST_USERS;
- await page.goto('/signin');
-
- // sign in
- await page.getByLabel('Email').fill(sender.email);
- await page.getByLabel('Password', { exact: true }).fill(sender.password);
- await page.getByRole('button', { name: 'Sign In' }).click();
-
+ await manualLogin({ page, email: sender.email, password: sender.password });
await page.waitForURL('/documents');
// open actions menu
diff --git a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts
index e9ae60d0e..160113f95 100644
--- a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts
+++ b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts
@@ -17,12 +17,6 @@ test('[PR-713]: should see sent documents', async ({ page }) => {
await page.getByPlaceholder('Type a command or search...').fill('sent');
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
-
- await page.keyboard.press('Escape');
-
- // signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
});
test('[PR-713]: should see received documents', async ({ page }) => {
@@ -40,12 +34,6 @@ test('[PR-713]: should see received documents', async ({ page }) => {
await page.getByPlaceholder('Type a command or search...').fill('received');
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
-
- await page.keyboard.press('Escape');
-
- // signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
});
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
@@ -63,10 +51,4 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => {
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
-
- await page.keyboard.press('Escape');
-
- // signout
- await page.getByTitle('Profile Dropdown').click();
- await page.getByRole('menuitem', { name: 'Sign Out' }).click();
});
diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts
new file mode 100644
index 000000000..aed56b2bc
--- /dev/null
+++ b/packages/app-tests/e2e/teams/manage-team.spec.ts
@@ -0,0 +1,87 @@
+import { test } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
+import { seedUser } from '@documenso/prisma/seed/users';
+
+import { manualLogin } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+test('[TEAMS]: create team', async ({ page }) => {
+ const user = await seedUser();
+
+ await manualLogin({
+ page,
+ email: user.email,
+ redirectPath: '/settings/teams',
+ });
+
+ const teamId = `team-${Date.now()}`;
+
+ // Create team.
+ await page.getByRole('button', { name: 'Create team' }).click();
+ await page.getByLabel('Team Name*').fill(teamId);
+ await page.getByTestId('dialog-create-team-button').click();
+
+ await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' });
+
+ const isCheckoutRequired = page.url().includes('pending');
+ test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.');
+
+ // Goto new team settings page.
+ await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click();
+
+ await unseedTeam(teamId);
+});
+
+test('[TEAMS]: delete team', async ({ page }) => {
+ const team = await seedTeam();
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ redirectPath: `/t/${team.url}/settings`,
+ });
+
+ // Delete team.
+ await page.getByRole('button', { name: 'Delete team' }).click();
+ await page.getByLabel(`Confirm by typing delete ${team.url}`).fill(`delete ${team.url}`);
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ // Check that we have been redirected to the teams page.
+ await page.waitForURL(`${WEBAPP_BASE_URL}/settings/teams`);
+});
+
+test('[TEAMS]: update team', async ({ page }) => {
+ const team = await seedTeam();
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ });
+
+ // Navigate to create team page.
+ await page.getByTestId('menu-switcher').click();
+ await page.getByRole('menuitem', { name: 'Manage teams' }).click();
+
+ // Goto team settings page.
+ await page.getByRole('row').filter({ hasText: team.url }).getByRole('link').nth(1).click();
+
+ const updatedTeamId = `team-${Date.now()}`;
+
+ // Update team.
+ await page.getByLabel('Team Name*').click();
+ await page.getByLabel('Team Name*').clear();
+ await page.getByLabel('Team Name*').fill(updatedTeamId);
+ await page.getByLabel('Team URL*').click();
+ await page.getByLabel('Team URL*').clear();
+ await page.getByLabel('Team URL*').fill(updatedTeamId);
+
+ await page.getByRole('button', { name: 'Update team' }).click();
+
+ // Check we have been redirected to the new team URL and the name is updated.
+ await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`);
+
+ await unseedTeam(updatedTeamId);
+});
diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts
new file mode 100644
index 000000000..210189ca7
--- /dev/null
+++ b/packages/app-tests/e2e/teams/team-documents.spec.ts
@@ -0,0 +1,282 @@
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import { DocumentStatus } from '@documenso/prisma/client';
+import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
+import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
+import { seedUser } from '@documenso/prisma/seed/users';
+
+import { manualLogin, manualSignout } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
+ await page.getByRole('tab', { name: tabName }).click();
+
+ if (tabName !== 'All') {
+ await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
+ }
+
+ if (count === 0) {
+ await expect(page.getByRole('main')).toContainText(`Nothing to do`);
+ return;
+ }
+
+ await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
+};
+
+test('[TEAMS]: check team documents count', async ({ page }) => {
+ const { team, teamMember2 } = await seedTeamDocuments();
+
+ // Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
+ for (const user of [team.owner, teamMember2]) {
+ await manualLogin({
+ page,
+ email: user.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ // Check document counts.
+ await checkDocumentTabCount(page, 'Inbox', 0);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 1);
+ await checkDocumentTabCount(page, 'Draft', 2);
+ await checkDocumentTabCount(page, 'All', 5);
+
+ // Apply filter.
+ await page.locator('button').filter({ hasText: 'Sender: All' }).click();
+ await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
+ await page.waitForURL(/senderIds/);
+
+ // Check counts after filtering.
+ await checkDocumentTabCount(page, 'Inbox', 0);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 0);
+ await checkDocumentTabCount(page, 'Draft', 1);
+ await checkDocumentTabCount(page, 'All', 3);
+
+ await manualSignout({ page });
+ }
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
+ const { team, teamMember2, teamMember4 } = await seedTeamDocuments();
+ const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
+
+ const teamEmailMember = teamMember4;
+
+ await seedTeamEmail({
+ email: teamEmailMember.email,
+ teamId: team.id,
+ });
+
+ const testUser1 = await seedUser();
+
+ await seedDocuments([
+ // Documents sent from the team email account.
+ {
+ sender: teamEmailMember,
+ recipients: [testUser1],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ },
+ },
+ {
+ sender: teamEmailMember,
+ recipients: [testUser1],
+ type: DocumentStatus.PENDING,
+ documentOptions: {
+ teamId: team.id,
+ },
+ },
+ {
+ sender: teamMember4,
+ recipients: [testUser1],
+ type: DocumentStatus.DRAFT,
+ },
+ // Documents sent to the team email account.
+ {
+ sender: testUser1,
+ recipients: [teamEmailMember],
+ type: DocumentStatus.COMPLETED,
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmailMember],
+ type: DocumentStatus.PENDING,
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmailMember],
+ type: DocumentStatus.DRAFT,
+ },
+ // Document sent to the team email account from another team.
+ {
+ sender: team2Member2,
+ recipients: [teamEmailMember],
+ type: DocumentStatus.PENDING,
+ documentOptions: {
+ teamId: team2.id,
+ },
+ },
+ ]);
+
+ // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
+ for (const user of [team.owner, teamEmailMember]) {
+ await manualLogin({
+ page,
+ email: user.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ // Check document counts.
+ await checkDocumentTabCount(page, 'Inbox', 2);
+ await checkDocumentTabCount(page, 'Pending', 3);
+ await checkDocumentTabCount(page, 'Completed', 3);
+ await checkDocumentTabCount(page, 'Draft', 3);
+ await checkDocumentTabCount(page, 'All', 11);
+
+ // Apply filter.
+ await page.locator('button').filter({ hasText: 'Sender: All' }).click();
+ await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
+ await page.waitForURL(/senderIds/);
+
+ // Check counts after filtering.
+ await checkDocumentTabCount(page, 'Inbox', 0);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 0);
+ await checkDocumentTabCount(page, 'Draft', 1);
+ await checkDocumentTabCount(page, 'All', 3);
+
+ await manualSignout({ page });
+ }
+
+ await unseedTeamEmail({ teamId: team.id });
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: check team documents count with external team email', async ({ page }) => {
+ const { team, teamMember2 } = await seedTeamDocuments();
+ const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
+
+ const teamEmail = `external-team-email-${team.id}@test.documenso.com`;
+
+ await seedTeamEmail({
+ email: teamEmail,
+ teamId: team.id,
+ });
+
+ const testUser1 = await seedUser();
+
+ await seedDocuments([
+ // Documents sent to the team email account.
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.COMPLETED,
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.PENDING,
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.DRAFT,
+ },
+ // Document sent to the team email account from another team.
+ {
+ sender: team2Member2,
+ recipients: [teamEmail],
+ type: DocumentStatus.PENDING,
+ documentOptions: {
+ teamId: team2.id,
+ },
+ },
+ // Document sent to the team email account from an individual user.
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.PENDING,
+ documentOptions: {
+ teamId: team2.id,
+ },
+ },
+ {
+ sender: testUser1,
+ recipients: [teamEmail],
+ type: DocumentStatus.DRAFT,
+ documentOptions: {
+ teamId: team2.id,
+ },
+ },
+ ]);
+
+ await manualLogin({
+ page,
+ email: teamMember2.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ // Check document counts.
+ await checkDocumentTabCount(page, 'Inbox', 3);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 2);
+ await checkDocumentTabCount(page, 'Draft', 2);
+ await checkDocumentTabCount(page, 'All', 9);
+
+ // Apply filter.
+ await page.locator('button').filter({ hasText: 'Sender: All' }).click();
+ await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
+ await page.waitForURL(/senderIds/);
+
+ // Check counts after filtering.
+ await checkDocumentTabCount(page, 'Inbox', 0);
+ await checkDocumentTabCount(page, 'Pending', 2);
+ await checkDocumentTabCount(page, 'Completed', 0);
+ await checkDocumentTabCount(page, 'Draft', 1);
+ await checkDocumentTabCount(page, 'All', 3);
+
+ await unseedTeamEmail({ teamId: team.id });
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: delete pending team document', async ({ page }) => {
+ const { team, teamMember2: currentUser } = await seedTeamDocuments();
+
+ await manualLogin({
+ page,
+ email: currentUser.email,
+ redirectPath: `/t/${team.url}/documents?status=PENDING`,
+ });
+
+ await page.getByRole('row').getByRole('button').nth(1).click();
+
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
+ await page.getByRole('button', { name: 'Delete' }).click();
+
+ await checkDocumentTabCount(page, 'Pending', 1);
+});
+
+test('[TEAMS]: resend pending team document', async ({ page }) => {
+ const { team, teamMember2: currentUser } = await seedTeamDocuments();
+
+ await manualLogin({
+ page,
+ email: currentUser.email,
+ redirectPath: `/t/${team.url}/documents?status=PENDING`,
+ });
+
+ await page.getByRole('row').getByRole('button').nth(1).click();
+ await page.getByRole('menuitem', { name: 'Resend' }).click();
+
+ await page.getByLabel('test.documenso.com').first().click();
+ await page.getByRole('button', { name: 'Send reminder' }).click();
+
+ await expect(page.getByRole('status')).toContainText('Document re-sent');
+});
diff --git a/packages/app-tests/e2e/teams/team-email.spec.ts b/packages/app-tests/e2e/teams/team-email.spec.ts
new file mode 100644
index 000000000..953be5aaf
--- /dev/null
+++ b/packages/app-tests/e2e/teams/team-email.spec.ts
@@ -0,0 +1,102 @@
+import { expect, test } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
+import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
+
+import { manualLogin } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+test('[TEAMS]: send team email request', async ({ page }) => {
+ const team = await seedTeam();
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ password: 'password',
+ redirectPath: `/t/${team.url}/settings`,
+ });
+
+ await page.getByRole('button', { name: 'Add email' }).click();
+ await page.getByPlaceholder('eg. Legal').click();
+ await page.getByPlaceholder('eg. Legal').fill('test@test.documenso.com');
+ await page.getByPlaceholder('example@example.com').click();
+ await page.getByPlaceholder('example@example.com').fill('test@test.documenso.com');
+ await page.getByRole('button', { name: 'Add' }).click();
+
+ await expect(
+ page
+ .getByRole('status')
+ .filter({ hasText: 'We have sent a confirmation email for verification.' })
+ .first(),
+ ).toBeVisible();
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: accept team email request', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const teamEmailVerification = await seedTeamEmailVerification({
+ email: 'team-email-verification@test.documenso.com',
+ teamId: team.id,
+ });
+
+ await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`);
+ await expect(page.getByRole('heading')).toContainText('Team email verified!');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: delete team email', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ createTeamEmail: true,
+ });
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ redirectPath: `/t/${team.url}/settings`,
+ });
+
+ await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
+
+ await page.getByRole('menuitem', { name: 'Remove' }).click();
+
+ await expect(page.getByText('Team email has been removed').first()).toBeVisible();
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: team email owner removes access', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ createTeamEmail: true,
+ });
+
+ if (!team.teamEmail) {
+ throw new Error('Not possible');
+ }
+
+ const teamEmailOwner = await seedUser({
+ email: team.teamEmail.email,
+ });
+
+ await manualLogin({
+ page,
+ email: teamEmailOwner.email,
+ redirectPath: `/settings/teams`,
+ });
+
+ await page.getByRole('button', { name: 'Revoke access' }).click();
+ await page.getByRole('button', { name: 'Revoke' }).click();
+
+ await expect(page.getByText('You have successfully revoked').first()).toBeVisible();
+
+ await unseedTeam(team.url);
+ await unseedUser(teamEmailOwner.id);
+});
diff --git a/packages/app-tests/e2e/teams/team-members.spec.ts b/packages/app-tests/e2e/teams/team-members.spec.ts
new file mode 100644
index 000000000..05f096c09
--- /dev/null
+++ b/packages/app-tests/e2e/teams/team-members.spec.ts
@@ -0,0 +1,110 @@
+import { expect, test } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
+import { seedUser } from '@documenso/prisma/seed/users';
+
+import { manualLogin } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+test('[TEAMS]: update team member role', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ password: 'password',
+ redirectPath: `/t/${team.url}/settings/members`,
+ });
+
+ const teamMemberToUpdate = team.members[1];
+
+ await page
+ .getByRole('row')
+ .filter({ hasText: teamMemberToUpdate.user.email })
+ .getByRole('button')
+ .click();
+
+ await page.getByRole('menuitem', { name: 'Update role' }).click();
+ await page.getByRole('combobox').click();
+ await page.getByLabel('Manager').click();
+ await page.getByRole('button', { name: 'Update' }).click();
+ await expect(
+ page.getByRole('row').filter({ hasText: teamMemberToUpdate.user.email }),
+ ).toContainText('Manager');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: accept team invitation without account', async ({ page }) => {
+ const team = await seedTeam();
+
+ const teamInvite = await seedTeamInvite({
+ email: `team-invite-test-${Date.now()}@test.documenso.com`,
+ teamId: team.id,
+ });
+
+ await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
+ await expect(page.getByRole('heading')).toContainText('Team invitation');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: accept team invitation with account', async ({ page }) => {
+ const team = await seedTeam();
+ const user = await seedUser();
+
+ const teamInvite = await seedTeamInvite({
+ email: user.email,
+ teamId: team.id,
+ });
+
+ await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
+ await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: member can leave team', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const teamMember = team.members[1];
+
+ await manualLogin({
+ page,
+ email: teamMember.user.email,
+ password: 'password',
+ redirectPath: `/settings/teams`,
+ });
+
+ await page.getByRole('button', { name: 'Leave' }).click();
+ await page.getByRole('button', { name: 'Leave' }).click();
+
+ await expect(page.getByRole('status').first()).toContainText(
+ 'You have successfully left this team.',
+ );
+
+ await unseedTeam(team.url);
+});
+
+test('[TEAMS]: owner cannot leave team', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ password: 'password',
+ redirectPath: `/settings/teams`,
+ });
+
+ await expect(page.getByRole('button').getByText('Leave')).toBeDisabled();
+
+ await unseedTeam(team.url);
+});
diff --git a/packages/app-tests/e2e/teams/transfer-team.spec.ts b/packages/app-tests/e2e/teams/transfer-team.spec.ts
new file mode 100644
index 000000000..a5d95b720
--- /dev/null
+++ b/packages/app-tests/e2e/teams/transfer-team.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from '@playwright/test';
+
+import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
+import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
+
+import { manualLogin } from '../fixtures/authentication';
+
+test.describe.configure({ mode: 'parallel' });
+
+test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const teamMember = team.members[1];
+
+ await manualLogin({
+ page,
+ email: team.owner.email,
+ password: 'password',
+ redirectPath: `/t/${team.url}/settings`,
+ });
+
+ await page.getByRole('button', { name: 'Transfer team' }).click();
+
+ await page.getByRole('combobox').click();
+ await page.getByLabel(teamMember.user.name ?? '').click();
+ await page.getByLabel('Confirm by typing transfer').click();
+ await page.getByLabel('Confirm by typing transfer').fill('transfer');
+ await page.getByRole('button', { name: 'Transfer' }).click();
+
+ await expect(page.locator('[id="\\:r2\\:-form-item-message"]')).toContainText(
+ `You must enter 'transfer ${team.name}' to proceed`,
+ );
+
+ await page.getByLabel('Confirm by typing transfer').click();
+ await page.getByLabel('Confirm by typing transfer').fill(`transfer ${team.name}`);
+ await page.getByRole('button', { name: 'Transfer' }).click();
+
+ await expect(page.getByRole('heading', { name: 'Team transfer in progress' })).toBeVisible();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ await expect(page.getByRole('status').first()).toContainText(
+ 'The team transfer invitation has been successfully deleted.',
+ );
+
+ await unseedTeam(team.url);
+});
+
+/**
+ * Current skipped until we disable billing during tests.
+ */
+test.skip('[TEAMS]: accept team transfer', async ({ page }) => {
+ const team = await seedTeam({
+ createTeamMembers: 1,
+ });
+
+ const newOwnerMember = team.members[1];
+
+ const teamTransferRequest = await seedTeamTransfer({
+ teamId: team.id,
+ newOwnerUserId: newOwnerMember.userId,
+ });
+
+ await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`);
+ await expect(page.getByRole('heading')).toContainText('Team ownership transferred!');
+
+ await unseedTeam(team.url);
+});
diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts
index 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/static/add-user.png b/packages/email/static/add-user.png
new file mode 100644
index 0000000000000000000000000000000000000000..abd337ceb70d306c70f31d07f1c74e2ca34399be
GIT binary patch
literal 3361
zcmV++4c_vJP)Gv`oIefP1`wEzQaL0KGNQ&`y?Uh=78W#-p`*s);-W_U
zGcz;iRRRbdVk;eN}Y;suHZD8npcaxE1d`GfO#C`k`2XJ3-i082610B20TR31@FMaS~g
zQ2n%>OiOwhAenH=G)1|T;zU(6q<3Os!Yh?ZBXrnj3OF?w?a@cZ9`b|AoI8>eBbe3w
z`1rV{f*rztIUFe(LL2ogqL9@TWB63kXP#&vne&=xGTl}U0F_VW5Q$sS#)4i{83OWy%E!lf
zlHx?(gKc$;0>$`j7Jz&pKd|m`Z1%aaw!Av7xBRfIqRoHwL66e$fA;R(8_A%4v%vHo
zOa`DleSh%a!70vPT<`yBpq2zj^`PAL`Uf=l{y*$#*iNMhOx6QPitqEYq9R7AqA}Jz
zGc)sxN(o5~Hk8#LwxdUnE>a;*fBN)Er3q9pdd8`tp`o{`Qkz94!K9%u)%TQ235mRj
zaz&Ab484Qif{LWRV3h_SyzOzd?FY@q{Ja8S8!`?Abx#p)z@9u)(``@7``|ix=L#
zd#BR?=6)f4>NXul-Z)=6VBuLSpfrlKJym&zRdD`(Y;0`Pix)2@W@l&hmoHy*l0bTx
zfQQ28if5CoB0|~(C(;>g43`aUIvF4TBC&aY(X~lZ)v=L`co9F+IdtgIgv^($aCsY1N^CTVyRzhx8)VlMN$i+8pD>_kq#BI8i6eHPM4%eMKcdpRW)AMg_W?f=yWO5#iHhRDI_Y?=Z
zMgL0H`ZT3czQ;DMgnbaH2!mkSojGvez_3aIH9f*a{lF&93RTG3n+f1ObwZpaV*d`J
zF(`p4HXxeG#yP};UMXxO@rtC@?;WG_)3l$wa^=b-r^E5GF%XcCn$L>9`x>*2$PU3iTu6bk<$wK{p>!iD4X
z&?jm86Ll|+hD5zcao;DB|4&ifrm#~Wl;ZLbmoex(*c1PV_R*@^RR?(+_<6bYb>Qah|le5^QA-PRfj)?xkPc}2PtlT}$gmRYu?kBJ1iT5s%yV3JLx&_PC&B$Y{4
zIhuFXHqh(dS#ellgxSM-s3CBt*I^@Wq@GJ0>Q*Q
zLpG%PhrBo{jeW`vmQGSU=OL-^Ntu@8vwFburj7IPTzm>3HVH;M+uPd<*ZiX(7Owf*
z1?*RvaDQcGklM
z4f7d*QF`72j@v=o^=ev0%WE*MI)Pxoc!@v8bGNgurd#&WVTSoe@@ib!Fa#Iy6SM{@YiQQntX!Fg
zSEN;ZO)T$iFu=}SXgqoM(9n?IKb_@;Y)yHa<+;e%N~Kakbs(^EU#~lF$!uHPl($(o
zJ#`Jj$46!hq^W7Ma;SIoXgM5}@hRbuRD`wNBW;LRrfa!c3UaL^osxEmbLB9_fbS~ll-)B$
zf_gg9g9<^3LM#S2F4LA@D_zAemaGJV_ze!ybbuqYpmddRre^jkEKQEfzOLGt)J9Q_
zps@d8(hx>+(Q?0Ejl7`i*7?8>b=joam8)Y325hNRRY(XTL|H`0LbVAF(-1+Vif~G)
zP!D-w6+~HiQ|+jhW6P*6d(Gd_8Sjbf8-nrmAVV(beAsZEWdH?T*Rlz+9j)m~r)#+#
zIg7Lc$O5gBhF8b`y6)E*9;bD1h#yV@@{i847CQ}2f*o=R`C4S33%ft;dQcqI?zh7P
r9fzHBaBy&NaBy&NaBy&NsEOYJQ|7+=Yxnzq00000NkvXXu0mjfB)pO|
literal 0
HcmV?d00001
diff --git a/packages/email/static/mail-open-alert.png b/packages/email/static/mail-open-alert.png
new file mode 100644
index 0000000000000000000000000000000000000000..1511f0bc539302bf3fc9de7e18283d8882861ffb
GIT binary patch
literal 3818
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
BjST<*
literal 0
HcmV?d00001
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 = ({