Merge branch 'main' into feat/public-api

This commit is contained in:
Lucas Smith
2024-02-09 16:00:40 +11:00
committed by GitHub
401 changed files with 20803 additions and 2358 deletions

View File

@ -7,9 +7,9 @@ import Link from 'next/link';
import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { 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 (

View File

@ -0,0 +1,82 @@
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Role } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
type ComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
const [open, setOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
const dbRoles = Object.values(Role);
React.useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const allRoles = [...new Set([...dbRoles, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={selectedValues.join(', ')} />
<CommandEmpty>No value found.</CommandEmpty>
<CommandGroup>
{allRoles.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{value}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};
export { MultiSelectRoleCombobox };

View File

@ -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 { MultiSelectRoleCombobox } from './multiselect-role-combobox';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl>
<Combobox
<MultiSelectRoleCombobox
listValues={roles}
onChange={(values: string[]) => onChange(values)}
/>

View File

@ -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);

View File

@ -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 const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
redirect(documentRootPath);
}
const { user } = await getRequiredServerComponentSession();
const document = await getDocumentById({
id: documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!document || !document.documentData) {
redirect(documentRootPath);
}
const { documentData, documentMeta } = document;
if (documentMeta?.password) {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const securePassword = Buffer.from(
symmetricDecrypt({
key,
data: documentMeta.password,
}),
).toString('utf-8');
documentMeta.password = securePassword;
}
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
userId: user.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
{document.status !== InternalDocumentStatus.COMPLETED && (
<EditDocumentForm
className="mt-8"
document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath}
/>
)}
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer
document={document}
key={documentData.id}
documentMeta={documentMeta}
documentData={documentData}
/>
</div>
)}
</div>
);
};

View File

@ -4,8 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@ -29,8 +29,10 @@ export type EditDocumentFormProps = {
user: User;
document: DocumentWithData;
recipients: Recipient[];
documentMeta: DocumentMeta | null;
fields: Field[];
documentData: DocumentData;
documentRootPath: string;
};
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
@ -41,8 +43,10 @@ export const EditDocumentForm = ({
document,
recipients,
fields,
documentMeta,
user: _user,
documentData,
documentRootPath,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
@ -56,6 +60,8 @@ export const EditDocumentForm = ({
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
title: {
@ -145,14 +151,16 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.email;
const { subject, message, timezone, dateFormat } = data.meta;
try {
await sendDocument({
documentId: document.id,
email: {
meta: {
subject,
message,
timezone,
dateFormat,
},
});
@ -162,7 +170,7 @@ export const EditDocumentForm = ({
duration: 5000,
});
router.push('/documents');
router.push(documentRootPath);
} catch (err) {
console.error(err);
@ -174,6 +182,13 @@ export const EditDocumentForm = ({
}
};
const onPasswordSubmit = async (password: string) => {
await setPasswordForDocument({
documentId: document.id,
password,
});
};
const currentDocumentFlow = documentFlow[step];
return (
@ -183,7 +198,13 @@ export const EditDocumentForm = ({
gradient
>
<CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
onPasswordSubmit={onPasswordSubmit}
/>
</CardContent>
</Card>
@ -199,9 +220,9 @@ export const EditDocumentForm = ({
<AddTitleFormPartial
key={recipients.length}
documentFlow={documentFlow.title}
document={document}
recipients={recipients}
fields={fields}
document={document}
onSubmit={onAddTitleFormSubmit}
/>

View File

@ -1,18 +1,4 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
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 { 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: {
@ -20,80 +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 } = document;
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
userId: user.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
{document.status !== InternalDocumentStatus.COMPLETED && (
<EditDocumentForm
className="mt-8"
document={document}
user={user}
recipients={recipients}
fields={fields}
documentData={documentData}
/>
)}
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</div>
)}
</div>
);
export default function DocumentPage({ params }: DocumentPageProps) {
return <DocumentPageView params={params} />;
}

View File

@ -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<Team, 'id' | 'url'> | null;
};
recipients: Recipient[];
team?: Pick<Team, 'id' | 'url'>;
};
export const ZResendDocumentFormSchema = z.object({
@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
export const ResendDocumentActionItem = ({
document,
recipients,
team,
}: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =
!isOwner ||
(!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',

View File

@ -2,13 +2,14 @@
import Link from 'next/link';
import { Download, Edit, Pencil } from 'lucide-react';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
@ -18,10 +19,12 @@ export type DataTableActionButtonProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
@ -37,6 +40,10 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;
const documentsPath = formatDocumentsPath(team?.url);
const onDownloadClick = async () => {
try {
@ -45,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
@ -55,33 +63,24 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const documentData = document?.documentData;
if (!documentData) {
return;
throw Error('No document available');
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (error) {
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while trying to download file.',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
}
};
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
}
return match({
isOwner,
isRecipient,
@ -89,27 +88,48 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
isPending,
isComplete,
isSigned,
isCurrentTeamDocument,
})
.with({ isOwner: true, isDraft: true }, () => (
<Button className="w-32" asChild>
<Link href={`/documents/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
))
.with(
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
<Button className="w-32" asChild>
<Link href={`${documentsPath}/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
),
)
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</>
))}
</Link>
</Button>
))
.with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}>
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
Sign
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</Button>
))
.with({ isComplete: true }, () => (

View File

@ -5,9 +5,11 @@ import { useState } from 'react';
import Link from 'next/link';
import {
CheckCircle,
Copy,
Download,
Edit,
EyeIcon,
Loader,
MoreHorizontal,
Pencil,
@ -17,9 +19,10 @@ import {
} from 'lucide-react';
import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import type { 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';
@ -30,6 +33,7 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDocumentDialog } from './delete-document-dialog';
@ -39,11 +43,14 @@ export type DataTableActionDropdownProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
@ -61,44 +68,43 @@ 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 () => {
let document: DocumentWithData | null = null;
try {
let document: DocumentWithData | null = null;
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
teamId: team?.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
}
const documentData = document?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
}
const documentData = document?.documentData;
if (!documentData) {
return;
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
@ -108,15 +114,35 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="mr-2 h-4 w-4" />
Sign
</Link>
</DropdownMenuItem>
{recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
View
</>
)}
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
<Link href={`/documents/${row.id}`}>
{recipient?.role === RecipientRole.SIGNER && (
<>
<Pencil className="mr-2 h-4 w-4" />
Sign
</>
)}
{recipient?.role === RecipientRole.APPROVER && (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</>
)}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
@ -144,7 +170,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuLabel>Share</DropdownMenuLabel>
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
<DocumentShareButton
documentId={row.id}
@ -164,6 +190,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DeleteDocumentDialog
id={row.id}
status={row.status}
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
@ -173,6 +200,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
team={team}
/>
)}
</DropdownMenu>

View File

@ -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 (
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="text-muted-foreground font-normal">
<span className="text-muted-foreground/70">Sender:</span> All
</p>
}
enableClearAllButton={true}
inputPlaceholder="Search"
loading={!isMounted || isInitialLoading}
options={comboBoxOptions}
selectedValues={senderIds}
onChange={onChange}
/>
);
};

View File

@ -7,7 +7,7 @@ import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, 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<User, 'id' | 'name' | 'email'>;
team: Pick<Team, 'id' | 'url'> | null;
}
>;
showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'>;
};
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 }) => <DataTableTitle row={row.original} />,
},
{
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) && (
<div className="flex items-center gap-x-4">
<DataTableActionButton row={row.original} />
<DataTableActionDropdown row={row.original} />
<DataTableActionButton team={team} row={row.original} />
<DataTableActionDropdown team={team} row={row.original} />
</div>
),
},
@ -90,6 +102,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: Boolean(showSenderColumn),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
@ -21,6 +21,7 @@ type DeleteDraftDocumentDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
documentTitle: string;
};
export const DeleteDocumentDialog = ({
@ -28,6 +29,7 @@ export const DeleteDocumentDialog = ({
open,
onOpenChange,
status,
documentTitle,
}: DeleteDraftDocumentDialogProps) => {
const router = useRouter();
@ -42,7 +44,7 @@ export const DeleteDocumentDialog = ({
toast({
title: 'Document deleted',
description: 'Your document has been successfully deleted.',
description: `"${documentTitle}" has been successfully deleted`,
duration: 5000,
});
@ -50,6 +52,13 @@ export const DeleteDocumentDialog = ({
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onDelete = async () => {
try {
await deleteDocument({ id, status });
@ -72,7 +81,7 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this document?</DialogTitle>
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be
@ -81,7 +90,7 @@ export const DeleteDocumentDialog = ({
</DialogHeader>
{status !== DocumentStatus.DRAFT && (
<div className="mt-8">
<div className="mt-4">
<Input
type="text"
value={inputValue}

View File

@ -0,0 +1,155 @@
import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team, TeamEmail } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
import { DataTableSenderFilter } from './data-table-sender-filter';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
export type DocumentsPageViewProps = {
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
senderIds?: string;
};
team?: Team & { teamEmail?: TeamEmail | null };
};
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
const { user } = await getRequiredServerComponentSession();
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const currentTeam = team ? { id: team.id, url: team.url } : undefined;
const getStatOptions: GetStatsInput = {
user,
period,
};
if (team) {
getStatOptions.team = {
teamId: team.id,
teamEmail: team.teamEmail?.email,
senderIds,
};
}
const stats = await getStats(getStatOptions);
const results = await findDocuments({
userId: user.id,
teamId: team?.id,
status,
orderBy: {
column: 'createdAt',
direction: 'desc',
},
page,
perPage,
period,
senderIds,
});
const getTabHref = (value: typeof status) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (params.has('page')) {
params.delete('page');
}
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<UploadDocument team={currentTeam} />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="text-4xl font-semibold">Documents</h1>
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DataTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
{results.count > 0 && (
<DocumentsDataTable
results={results}
showSenderColumn={team !== undefined}
team={currentTeam}
/>
)}
{results.count === 0 && <EmptyDocumentState status={status} />}
</div>
</div>
);
};

View File

@ -1,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<Team, 'id' | 'url'>;
};
export const DuplicateDocumentDialog = ({
id,
open,
onOpenChange,
team,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
teamId: team?.id,
});
const documentData = document?.documentData
@ -37,10 +42,13 @@ 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',
description: 'Your document has been successfully duplicated.',
@ -53,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',

View File

@ -1,114 +1,16 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
import type { DocumentsPageViewProps } from './documents-page-view';
import { DocumentsPageView } from './documents-page-view';
export type DocumentsPageProps = {
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
};
searchParams?: DocumentsPageViewProps['searchParams'];
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const { user } = await getRequiredServerComponentSession();
export const metadata: Metadata = {
title: 'Documents',
};
const stats = await getStats({
user,
});
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const results = await findDocuments({
userId: user.id,
status,
orderBy: {
column: 'createdAt',
direction: 'desc',
},
page,
perPage,
period,
});
const getTabHref = (value: typeof status) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (params.has('page')) {
params.delete('page');
}
return `/documents?${params.toString()}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<UploadDocument />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<h1 className="text-4xl font-semibold">Documents</h1>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs defaultValue={status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
{results.count > 0 && <DocumentsDataTable results={results} />}
{results.count === 0 && <EmptyDocumentState status={status} />}
</div>
</div>
);
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
return <DocumentsPageView searchParams={searchParams} />;
}

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
@ -10,8 +10,10 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@ -20,11 +22,16 @@ 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();
const { toast } = useToast();
@ -35,6 +42,18 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
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, team]);
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
@ -49,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { id } = await createDocument({
title: file.name,
documentDataId,
teamId: team?.id,
});
toast({
@ -63,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);
@ -85,20 +105,33 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
}
};
const onFileDropRejected = () => {
toast({
title: 'Your document failed to upload.',
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
duration: 5000,
variant: 'destructive',
});
};
return (
<div className={cn('relative', className)}>
<DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
/>
<div className="absolute -bottom-6 right-0">
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
</p>
)}
{team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
</p>
)}
</div>
{isLoading && (
@ -107,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
</div>
)}
{remaining.documents === 0 && (
{team?.id === undefined && remaining.documents === 0 && (
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
<div className="text-center">
<h2 className="text-muted-foreground/80 text-xl font-semibold">

View File

@ -7,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 (
<NextAuthProvider session={session}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Header user={user} />
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@ -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<typeof Button>;
};
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
};
return (
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
<Button
{...buttonProps}
onClick={async () => handleFetchPortalUrl()}
loading={isFetchingPortalUrl}
>
Manage Subscription
</Button>
);

View File

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

View File

@ -1,17 +1,20 @@
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { ProfileForm } from '~/components/forms/profile';
export const metadata: Metadata = {
title: 'Profile',
};
export default async function ProfileSettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<h3 className="text-2xl font-semibold">Profile</h3>
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
<hr className="my-4" />
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
<ProfileForm user={user} className="max-w-xl" />
</div>

View File

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

View File

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

View File

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

View File

@ -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 (
<Button
onClick={async () => acceptTeamInvitation({ teamId })}
loading={isLoading}
disabled={isLoading || isSuccess}
>
Accept
</Button>
);
};

View File

@ -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 (
<div>
<SettingsHeader title="Teams" subtitle="Manage all teams you are currently associated with.">
<CreateTeamDialog />
</SettingsHeader>
<UserSettingsTeamsPageDataTable />
<div className="mt-8 space-y-8">
<AnimatePresence>
{teamEmail && (
<AnimateGenericFadeInOut>
<TeamEmailUsage teamEmail={teamEmail} />
</AnimateGenericFadeInOut>
)}
</AnimatePresence>
<TeamInvitations />
</div>
</div>
);
}

View File

@ -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 (
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
<div>
<AlertTitle className="mb-0">Team Email</AlertTitle>
<AlertDescription>
<p>
Your email is currently being used by team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
).
</p>
<p className="mt-1">They have permission on your behalf to:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Display your name and email in documents</li>
<li>View all documents sent to your account</li>
</ul>
</AlertDescription>
</div>
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive">Revoke access</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription className="mt-4">
You are about to revoke access for team{' '}
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}) to
use your email.
</DialogDescription>
</DialogHeader>
<fieldset disabled={isDeletingTeamEmail}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingTeamEmail}
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
>
Revoke
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
</Alert>
);
};

View File

@ -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 (
<AnimatePresence>
{data && data.length > 0 && !isInitialLoading && (
<AnimateGenericFadeInOut>
<Alert variant="secondary">
<div className="flex h-full flex-row items-center p-2">
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
<AlertDescription className="mr-2">
You have <strong>{data.length}</strong> pending team invitation
{data.length > 1 ? 's' : ''}.
</AlertDescription>
<Dialog>
<DialogTrigger asChild>
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
View invites
</button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Pending invitations</DialogTitle>
<DialogDescription className="mt-4">
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
</DialogDescription>
</DialogHeader>
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
{data.map((invitation) => (
<li key={invitation.teamId}>
<AvatarWithText
className="w-full max-w-none py-4"
avatarFallback={invitation.team.name.slice(0, 1)}
primaryText={
<span className="text-foreground/80 font-semibold">
{invitation.team.name}
</span>
}
secondaryText={formatTeamUrl(invitation.team.url)}
rightSideComponent={
<div className="ml-auto">
<AcceptTeamInvitationButton teamId={invitation.team.id} />
</div>
}
/>
</li>
))}
</ul>
</DialogContent>
</Dialog>
</div>
</Alert>
</AnimateGenericFadeInOut>
)}
</AnimatePresence>
);
};

View File

@ -0,0 +1,158 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EditTemplateFormProps = {
className?: string;
user: User;
template: Template;
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
templateRootPath: string;
};
type EditTemplateStep = 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
export const EditTemplateForm = ({
className,
template,
recipients,
fields,
user: _user,
documentData,
templateRootPath,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditTemplateStep>('signers');
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
signers: {
title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.',
stepIndex: 1,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
},
};
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await addTemplateSigners({
templateId: template.id,
signers: data.signers,
});
router.refresh();
setStep('fields');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
templateId: template.id,
fields: data.fields,
});
toast({
title: 'Template saved',
description: 'Your templates has been saved successfully.',
duration: 5000,
});
router.push(templateRootPath);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit}
/>
<AddTemplateFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};

View File

@ -0,0 +1,10 @@
import React from 'react';
import type { TemplatePageViewProps } from './template-page-view';
import { TemplatePageView } from './template-page-view';
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
export default function TemplatePage({ params }: TemplatePageProps) {
return <TemplatePageView params={params} />;
}

View File

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

View File

@ -0,0 +1,92 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import type { Template } from '@documenso/prisma/client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
export type DataTableActionDropdownProps = {
row: Template;
templateRootPath: string;
teamId?: number;
};
export const DataTableActionDropdown = ({
row,
templateRootPath,
teamId,
}: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
if (!session) {
return null;
}
const isOwner = row.userId === session.user.id;
const isTeamTemplate = row.teamId === teamId;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link href={`${templateRootPath}/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDuplicateDialogOpen(true)}
>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
<DuplicateTemplateDialog
id={row.id}
teamId={teamId}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
<DeleteTemplateDialog
id={row.id}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
</DropdownMenu>
);
};

View File

@ -0,0 +1,167 @@
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Loader, Plus } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Template } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { LocaleDate } from '~/components/formatter/locale-date';
import { TemplateType } from '~/components/formatter/template-type';
import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
type TemplatesDataTableProps = {
templates: Template[];
perPage: number;
page: number;
totalPages: number;
documentRootPath: string;
templateRootPath: string;
teamId?: number;
};
export const TemplatesDataTable = ({
templates,
perPage,
page,
totalPages,
documentRootPath,
templateRootPath,
teamId,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const { remaining } = useLimits();
const router = useRouter();
const { toast } = useToast();
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const onUseButtonClick = async (templateId: number) => {
try {
const { id } = await createDocumentFromTemplate({
templateId,
});
toast({
title: 'Document created',
description: 'Your document has been created from the template successfully.',
duration: 5000,
});
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
}
};
return (
<div className="relative">
{remaining.documents === 0 && (
<Alert variant="warning" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Document Limit Exceeded!</AlertTitle>
<AlertDescription className="mt-2">
You have reached your document limit.{' '}
<Link className="underline underline-offset-4" href="/settings/billing">
Upgrade your account to continue!
</Link>
</AlertDescription>
</Alert>
)}
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <TemplateType type={row.original.type} />,
},
{
header: 'Actions',
accessorKey: 'actions',
cell: ({ row }) => {
const isRowLoading = loadingStates[row.original.id];
return (
<div className="flex items-center gap-x-4">
<Button
disabled={isRowLoading || remaining.documents === 0}
loading={isRowLoading}
onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
await onUseButtonClick(row.original.id);
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
}}
>
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<DataTableActionDropdown
row={row.original}
teamId={teamId}
templateRootPath={templateRootPath}
/>
</div>
);
},
},
]}
data={templates}
perPage={perPage}
currentPage={page}
totalPages={totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,26 @@
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { Template } from '@documenso/prisma/client';
export type DataTableTitleProps = {
row: Template;
};
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const { data: session } = useSession();
if (!session) {
return null;
}
return (
<Link
href={`/templates/${row.id}`}
className="block max-w-[10rem] cursor-pointer truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
);
};

View File

@ -0,0 +1,77 @@
import { useRouter } from 'next/navigation';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteTemplateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template deleted',
description: 'Your template has been successfully deleted.',
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: 'Something went wrong',
description: 'This template could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this template?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,89 @@
import { useRouter } from 'next/navigation';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
id,
teamId,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isLoading } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template duplicated',
description: 'Your template has been duplicated successfully.',
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to duplicate this template?</DialogTitle>
<DialogDescription className="pt-2">Your template will be duplicated.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
disabled={isLoading}
variant="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
loading={isLoading}
onClick={async () =>
duplicateTemplate({
templateId: id,
teamId,
})
}
>
Duplicate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,17 @@
import { Bird } from 'lucide-react';
export const EmptyTemplateState = () => {
return (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">We're all empty</h3>
<p className="mt-2 max-w-[50ch]">
You have not yet created any templates. To create a template please upload one.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,235 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { FilePlus, X } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({
name: z.string(),
});
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
type NewTemplateDialogProps = {
teamId?: number;
templateRootPath: string;
};
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({
defaultValues: {
name: '',
},
resolver: zodResolver(ZCreateTemplateFormSchema),
});
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const onFileDrop = async (file: File) => {
try {
const arrayBuffer = await file.arrayBuffer();
const base64String = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64: `data:application/pdf;base64,${base64String}`,
});
if (!form.getValues('name')) {
form.setValue('name', file.name);
}
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const onSubmit = async (values: TCreateTemplateFormSchema) => {
if (!uploadedFile) {
return;
}
const file: File = uploadedFile.file;
try {
const { type, data } = await putFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,
data,
});
const { id } = await createTemplate({
teamId,
title: values.name ? values.name : file.name,
templateDocumentDataId,
});
toast({
title: 'Template document uploaded',
description:
'Your document has been uploaded successfully. You will be redirected to the template page.',
duration: 5000,
});
setShowNewTemplateDialog(false);
router.push(`${templateRootPath}/${id}`);
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const resetForm = () => {
if (form.getValues('name') === uploadedFile?.file.name) {
form.reset();
}
setUploadedFile(null);
};
useEffect(() => {
if (!showNewTemplateDialog) {
form.reset();
}
}, [form, showNewTemplateDialog]);
return (
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
New Template
</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-xl">
<DialogHeader>
<DialogTitle className="mb-4">New Template</DialogTitle>
</DialogHeader>
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name your template</FormLabel>
<FormControl>
<Input id="email" type="text" className="bg-background mt-1.5" {...field} />
</FormControl>
<FormDescription>
<span className="text-muted-foreground text-xs">
Leave this empty if you would like to use your document's name for the
template
</span>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<Label htmlFor="template">Upload a Document</Label>
<div className="my-3">
{uploadedFile ? (
<Card gradient className="h-[40vh]">
<CardContent className="flex h-full flex-col items-center justify-center p-2">
<button
onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</div>
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
Uploaded Document
</p>
<span className="text-muted-foreground/80 mt-1 text-sm">
{uploadedFile.file.name}
</span>
</CardContent>
</Card>
) : (
<DocumentDropzone
className="mt-1.5 h-[40vh]"
onDrop={onFileDrop}
type="template"
/>
)}
</div>
</div>
<div className="flex w-full justify-end">
<Button loading={isCreatingTemplate} type="submit">
Create Template
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,18 @@
import React from 'react';
import type { Metadata } from 'next';
import { TemplatesPageView } from './templates-page-view';
import type { TemplatesPageViewProps } from './templates-page-view';
type TemplatesPageProps = {
searchParams?: TemplatesPageViewProps['searchParams'];
};
export const metadata: Metadata = {
title: 'Templates',
};
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
return <TemplatesPageView searchParams={searchParams} />;
}

View File

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