diff --git a/.env.example b/.env.example
index d188894de..c1e4fa588 100644
--- a/.env.example
+++ b/.env.example
@@ -77,6 +77,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
+NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
+NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.
diff --git a/apps/web/process-env.d.ts b/apps/web/process-env.d.ts
index 0c00cb4c1..3005881f7 100644
--- a/apps/web/process-env.d.ts
+++ b/apps/web/process-env.d.ts
@@ -6,6 +6,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
+ NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
diff --git a/apps/web/public/static/add-user.png b/apps/web/public/static/add-user.png
new file mode 100644
index 000000000..abd337ceb
Binary files /dev/null and b/apps/web/public/static/add-user.png differ
diff --git a/apps/web/public/static/mail-open.png b/apps/web/public/static/mail-open.png
new file mode 100644
index 000000000..306313b03
Binary files /dev/null and b/apps/web/public/static/mail-open.png differ
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 3baf5d63b..d430eee1b 100644
--- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
+++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx
@@ -9,7 +9,6 @@ import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button';
-import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
FormControl,
@@ -21,6 +20,8 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
+import { RoleCombobox } from './role-combobox';
+
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer;
@@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
+ }
+ 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..77aa1d1c1 100644
--- a/apps/web/src/app/(dashboard)/documents/data-table.tsx
+++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx
@@ -27,9 +27,15 @@ export type DocumentsDataTableProps = {
User: Pick;
}
>;
+ showSenderColumn?: boolean;
+ teamUrl?: string;
};
-export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
+export const DocumentsDataTable = ({
+ results,
+ showSenderColumn,
+ teamUrl,
+}: DocumentsDataTableProps) => {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
@@ -61,6 +67,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 +90,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
(!row.original.deletedAt ||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
-
-
+
+
),
},
@@ -90,6 +101,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-component.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-component.tsx
new file mode 100644
index 000000000..8440894e3
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/documents/documents-page-component.tsx
@@ -0,0 +1,154 @@
+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 { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
+import { getStats } from '@documenso/lib/server-only/document/get-stats';
+import { parseToNumberArray } from '@documenso/lib/utils/params';
+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 {
+ type PeriodSelectorValue,
+ 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 DocumentsPageComponentProps = {
+ searchParams?: {
+ status?: ExtendedDocumentStatus;
+ period?: PeriodSelectorValue;
+ page?: string;
+ perPage?: string;
+ senderIds?: string;
+ };
+ team?: Team & { teamEmail?: TeamEmail };
+};
+
+export default async function DocumentsPageComponent({
+ searchParams = {},
+ team,
+}: DocumentsPageComponentProps) {
+ 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 documentsPath = team ? `/t/${team.url}/documents` : '/documents';
+ const senderIds = parseToNumberArray(searchParams.senderIds ?? '');
+
+ let teamStatOptions: GetStatsInput['team'] = undefined;
+
+ if (team) {
+ teamStatOptions = {
+ teamId: team.id,
+ teamEmail: team.teamEmail?.email,
+ senderIds,
+ };
+ }
+
+ const stats = await getStats({
+ user,
+ team: teamStatOptions,
+ });
+
+ 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 `${documentsPath}?${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..0a3912e7e 100644
--- a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
+++ b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx
@@ -16,12 +16,14 @@ type DuplicateDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
+ teamUrl?: string;
};
export const DuplicateDocumentDialog = ({
id,
open,
onOpenChange,
+ teamUrl,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
@@ -37,10 +39,12 @@ export const DuplicateDocumentDialog = ({
}
: undefined;
+ const documentsPath = teamUrl ? `/t/${teamUrl}/documents` : '/documents';
+
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
- router.push(`/documents/${newId}`);
+ router.push(`${documentsPath}/${newId}`);
toast({
title: 'Document Duplicated',
diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx
index f38668fd9..22fe16507 100644
--- a/apps/web/src/app/(dashboard)/documents/page.tsx
+++ b/apps/web/src/app/(dashboard)/documents/page.tsx
@@ -1,114 +1,10 @@
-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 { getStats } from '@documenso/lib/server-only/document/get-stats';
-import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
-import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
-import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
-
-import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
-import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
-import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
-import { DocumentStatus } from '~/components/formatter/document-status';
-
-import { DocumentsDataTable } from './data-table';
-import { EmptyDocumentState } from './empty-state';
-import { UploadDocument } from './upload-document';
+import type { DocumentsPageComponentProps } from './documents-page-component';
+import DocumentsPageComponent from './documents-page-component';
export type DocumentsPageProps = {
- searchParams?: {
- status?: ExtendedDocumentStatus;
- period?: PeriodSelectorValue;
- page?: string;
- perPage?: string;
- };
+ searchParams?: DocumentsPageComponentProps['searchParams'];
};
-export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
- const { user } = await getRequiredServerComponentSession();
-
- const stats = await getStats({
- user,
- });
-
- const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
- const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
- const page = Number(searchParams.page) || 1;
- const perPage = Number(searchParams.perPage) || 20;
-
- const results = await findDocuments({
- userId: user.id,
- status,
- orderBy: {
- column: 'createdAt',
- direction: 'desc',
- },
- page,
- perPage,
- period,
- });
-
- const getTabHref = (value: typeof status) => {
- const params = new URLSearchParams(searchParams);
-
- params.set('status', value);
-
- if (params.has('page')) {
- params.delete('page');
- }
-
- return `/documents?${params.toString()}`;
- };
-
- return (
-
-
-
-
-
Documents
-
-
-
-
- {[
- ExtendedDocumentStatus.INBOX,
- ExtendedDocumentStatus.PENDING,
- ExtendedDocumentStatus.COMPLETED,
- ExtendedDocumentStatus.DRAFT,
- ExtendedDocumentStatus.ALL,
- ].map((value) => (
-
-
-
-
- {value !== ExtendedDocumentStatus.ALL && (
-
- {Math.min(stats[value], 99)}
- {stats[value] > 99 && '+'}
-
- )}
-
-
- ))}
-
-
-
-
-
-
-
-
- {results.count > 0 && }
- {results.count === 0 && }
-
-
- );
+export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
+ return ;
}
diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
index 5e93495e3..df1b8679f 100644
--- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx
@@ -20,9 +20,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();
const { data: session } = useSession();
@@ -49,6 +53,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { id } = await createDocument({
title: file.name,
documentDataId,
+ teamId: team?.id,
});
toast({
@@ -63,7 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
timestamp: new Date().toISOString(),
});
- router.push(`/documents/${id}`);
+ router.push(team?.id !== undefined ? `/t/${team.url}/documents/${id}` : `/documents/${id}`);
} catch (error) {
console.error(error);
@@ -94,11 +99,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 && (
@@ -107,7 +114,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 (
-