diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx new file mode 100644 index 000000000..5d6cae4af --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx @@ -0,0 +1,69 @@ +'use client'; + +import Link from 'next/link'; + +import { type Document, DocumentStatus } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminActionsProps = { + className?: string; + document: Document; +}; + +export const AdminActions = ({ className, document }: AdminActionsProps) => { + const { toast } = useToast(); + + const { mutate: resealDocument, isLoading: isResealDocumentLoading } = + trpc.admin.resealDocument.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Document resealed', + }); + }, + onError: () => { + toast({ + title: 'Error', + description: 'Failed to reseal document', + variant: 'destructive', + }); + }, + }); + + return ( +
+ + + + + + + + Attempts sealing the document again, useful for after a code change has occurred to + resolve an erroneous document. + + + + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx new file mode 100644 index 000000000..a22345457 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx @@ -0,0 +1,86 @@ +import { DateTime } from 'luxon'; + +import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@documenso/ui/primitives/accordion'; +import { Badge } from '@documenso/ui/primitives/badge'; + +import { DocumentStatus } from '~/components/formatter/document-status'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { AdminActions } from './admin-actions'; +import { RecipientItem } from './recipient-item'; + +type AdminDocumentDetailsPageProps = { + params: { + id: string; + }; +}; + +export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) { + const document = await getEntireDocument({ id: Number(params.id) }); + + return ( +
+
+
+

{document.title}

+ +
+ + {document.deletedAt && ( + + Deleted + + )} +
+ +
+
+ Created on: +
+
+ Last updated at: +
+
+ +
+ +

Admin Actions

+ + + +
+

Recipients

+ +
+ + {document.Recipient.map((recipient) => ( + + +
+

{recipient.name}

+ + {recipient.email} + +
+
+ + + + +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx new file mode 100644 index 000000000..3bf8c78ab --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/recipient-item.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { + type Field, + type Recipient, + type Signature, + SigningStatus, +} from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZAdminUpdateRecipientFormSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), +}); + +type TAdminUpdateRecipientFormSchema = z.infer; + +export type RecipientItemProps = { + recipient: Recipient & { + Field: Array< + Field & { + Signature: Signature | null; + } + >; + }; +}; + +export const RecipientItem = ({ recipient }: RecipientItemProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm({ + defaultValues: { + name: recipient.name, + email: recipient.email, + }, + }); + + const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation(); + + const onUpdateRecipientFormSubmit = async ({ name, email }: TAdminUpdateRecipientFormSchema) => { + try { + await updateRecipient({ + id: recipient.id, + name, + email, + }); + + toast({ + title: 'Recipient updated', + description: 'The recipient has been updated successfully', + }); + + router.refresh(); + } catch (error) { + toast({ + title: 'Failed to update recipient', + description: error.message, + variant: 'destructive', + }); + } + }; + + return ( +
+
+ +
+ ( + + Name + + + + + + + + )} + /> + + ( + + Email + + + + + + + + )} + /> + +
+ +
+
+
+ + +
+ +

Fields

+ +
{row.original.id}
, + }, + { + header: 'Type', + accessorKey: 'type', + cell: ({ row }) =>
{row.original.type}
, + }, + { + header: 'Inserted', + accessorKey: 'inserted', + cell: ({ row }) =>
{row.original.inserted ? 'True' : 'False'}
, + }, + { + header: 'Value', + accessorKey: 'customText', + cell: ({ row }) =>
{row.original.customText}
, + }, + { + header: 'Signature', + accessorKey: 'signature', + cell: ({ row }) => ( +
+ {row.original.Signature?.typedSignature && ( + {row.original.Signature.typedSignature} + )} + + {row.original.Signature?.signatureImageAsBase64 && ( + Signature + )} +
+ ), + }, + ]} + /> +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx deleted file mode 100644 index 0fc660968..000000000 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client'; - -import { useTransition } from 'react'; - -import Link from 'next/link'; - -import { Loader } from 'lucide-react'; - -import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -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'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; - -import { DocumentStatus } from '~/components/formatter/document-status'; -import { LocaleDate } from '~/components/formatter/locale-date'; - -export type DocumentsDataTableProps = { - results: FindResultSet< - Document & { - User: Pick; - } - >; -}; - -export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { - const [isPending, startTransition] = useTransition(); - - const updateSearchParams = useUpdateSearchParams(); - - const onPaginationChange = (page: number, perPage: number) => { - startTransition(() => { - updateSearchParams({ - page, - perPage, - }); - }); - }; - - return ( -
- , - }, - { - header: 'Title', - accessorKey: 'title', - cell: ({ row }) => { - return ( -
- {row.original.title} -
- ); - }, - }, - { - header: 'Owner', - accessorKey: 'owner', - cell: ({ row }) => { - const avatarFallbackText = row.original.User.name - ? extractInitials(row.original.User.name) - : row.original.User.email.slice(0, 1).toUpperCase(); - - return ( - - - - - - {avatarFallbackText} - - - - - - - - {avatarFallbackText} - - - -
- {row.original.User.name} - {row.original.User.email} -
-
-
- ); - }, - }, - { - header: 'Last updated', - accessorKey: 'updatedAt', - cell: ({ row }) => , - }, - { - header: 'Status', - accessorKey: 'status', - cell: ({ row }) => , - }, - ]} - data={results.data} - perPage={results.perPage} - currentPage={results.currentPage} - totalPages={results.totalPages} - onPaginationChange={onPaginationChange} - > - {(table) => } -
- - {isPending && ( -
- -
- )} -
- ); -}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/document-results.tsx b/apps/web/src/app/(dashboard)/admin/documents/document-results.tsx new file mode 100644 index 000000000..b7e235981 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/document-results.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { trpc } from '@documenso/trpc/react'; +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'; +import { Input } from '@documenso/ui/primitives/input'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; + +import { DocumentStatus } from '~/components/formatter/document-status'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +// export type AdminDocumentResultsProps = {}; + +export const AdminDocumentResults = () => { + const searchParams = useSearchParams(); + + const updateSearchParams = useUpdateSearchParams(); + + const [term, setTerm] = useState(() => searchParams?.get?.('term') ?? ''); + const debouncedTerm = useDebouncedValue(term, 500); + + const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined; + const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined; + + const { data: findDocumentsData, isLoading: isFindDocumentsLoading } = + trpc.admin.findDocuments.useQuery( + { + term: debouncedTerm, + page: page || 1, + perPage: perPage || 20, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (newPage: number, newPerPage: number) => { + updateSearchParams({ + page: newPage, + perPage: newPerPage, + }); + }; + + return ( +
+ setTerm(e.target.value)} + /> + +
+ , + }, + { + header: 'Title', + accessorKey: 'title', + cell: ({ row }) => { + return ( + + {row.original.title} + + ); + }, + }, + { + header: 'Status', + accessorKey: 'status', + cell: ({ row }) => , + }, + { + header: 'Owner', + accessorKey: 'owner', + cell: ({ row }) => { + const avatarFallbackText = row.original.User.name + ? extractInitials(row.original.User.name) + : row.original.User.email.slice(0, 1).toUpperCase(); + + return ( + + + + + + {avatarFallbackText} + + + + + + + + + {avatarFallbackText} + + + +
+ {row.original.User.name} + {row.original.User.email} +
+
+
+ ); + }, + }, + { + header: 'Last updated', + accessorKey: 'updatedAt', + cell: ({ row }) => , + }, + ]} + data={findDocumentsData?.data ?? []} + perPage={findDocumentsData?.perPage ?? 20} + currentPage={findDocumentsData?.currentPage ?? 1} + totalPages={findDocumentsData?.totalPages ?? 1} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isFindDocumentsLoading && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/page.tsx index 2fbbcd4dc..96e4dcef8 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/page.tsx @@ -1,28 +1,12 @@ -import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; - -import { DocumentsDataTable } from './data-table'; - -export type DocumentsPageProps = { - searchParams?: { - page?: string; - perPage?: string; - }; -}; - -export default async function Documents({ searchParams = {} }: DocumentsPageProps) { - const page = Number(searchParams.page) || 1; - const perPage = Number(searchParams.perPage) || 20; - - const results = await findDocuments({ - page, - perPage, - }); +import { AdminDocumentResults } from './document-results'; +export default function AdminDocumentsPage() { return (

Manage documents

+
- +
); diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx new file mode 100644 index 000000000..42e523ece --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/delete-user-dialog.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import type { User } from '@documenso/prisma/client'; +import { TRPCClientError } from '@documenso/trpc/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 { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DeleteUserDialogProps = { + className?: string; + user: User; +}; + +export const DeleteUserDialog = ({ className, user }: DeleteUserDialogProps) => { + const router = useRouter(); + const { toast } = useToast(); + + const [email, setEmail] = useState(''); + + const { mutateAsync: deleteUser, isLoading: isDeletingUser } = + trpc.admin.deleteUser.useMutation(); + + const onDeleteAccount = async () => { + try { + await deleteUser({ + id: user.id, + email, + }); + + toast({ + title: 'Account deleted', + description: 'The account has been deleted successfully.', + duration: 5000, + }); + + router.push('/admin/users'); + } catch (err) { + if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: err.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + err.message ?? + 'We encountered an unknown error while attempting to delete your account. Please try again later.', + }); + } + } + }; + + return ( +
+ +
+ Delete Account + + Delete the users account and all its contents. This action is irreversible and will + cancel their subscription, so proceed with caution. + +
+ +
+ + + + + + + + Delete Account + + + + This action is not reversible. Please be certain. + + + + +
+ + To confirm, please enter the accounts email address
({user.email}). +
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 3bd909623..b9068329a 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form'; import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; -import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; +import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Form, @@ -20,9 +20,10 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { DeleteUserDialog } from './delete-user-dialog'; import { MultiSelectRoleCombobox } from './multiselect-role-combobox'; -const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); +const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true }); type TUserFormSchema = z.infer; @@ -137,6 +138,10 @@ export default function UserPage({ params }: { params: { id: number } }) { + +
+ + {user && } ); } diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 577e0739a..1a5d2f554 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag const [{ users, totalPages }, individualPrices] = await Promise.all([ search(searchString, page, perPage), - getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY), + getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []), ]); const individualPriceIds = individualPrices.map((price) => price.id); diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 9eef1f4bd..262e297d6 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -57,7 +57,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { key={href} href={`${rootHref}${href}`} className={cn( - 'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2', + 'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2', { 'text-foreground dark:text-muted-foreground': pathname?.startsWith( `${rootHref}${href}`, diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index 65bb63230..b0ede5b8b 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -86,7 +86,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => { >
diff --git a/packages/lib/client-only/hooks/use-copy-share-link.ts b/packages/lib/client-only/hooks/use-copy-share-link.ts index 255949e3c..cff552e8f 100644 --- a/packages/lib/client-only/hooks/use-copy-share-link.ts +++ b/packages/lib/client-only/hooks/use-copy-share-link.ts @@ -1,5 +1,5 @@ import { trpc } from '@documenso/trpc/react'; -import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema'; +import type { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema'; import { useCopyToClipboard } from './use-copy-to-clipboard'; diff --git a/packages/lib/server-only/admin/get-entire-document.ts b/packages/lib/server-only/admin/get-entire-document.ts new file mode 100644 index 000000000..e74ee4c7b --- /dev/null +++ b/packages/lib/server-only/admin/get-entire-document.ts @@ -0,0 +1,26 @@ +import { prisma } from '@documenso/prisma'; + +export type GetEntireDocumentOptions = { + id: number; +}; + +export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => { + const document = await prisma.document.findFirstOrThrow({ + where: { + id, + }, + include: { + Recipient: { + include: { + Field: { + include: { + Signature: true, + }, + }, + }, + }, + }, + }); + + return document; +}; diff --git a/packages/lib/server-only/admin/update-recipient.ts b/packages/lib/server-only/admin/update-recipient.ts new file mode 100644 index 000000000..dcd826476 --- /dev/null +++ b/packages/lib/server-only/admin/update-recipient.ts @@ -0,0 +1,30 @@ +import { prisma } from '@documenso/prisma'; +import { SigningStatus } from '@documenso/prisma/client'; + +export type UpdateRecipientOptions = { + id: number; + name: string | undefined; + email: string | undefined; +}; + +export const updateRecipient = async ({ id, name, email }: UpdateRecipientOptions) => { + const recipient = await prisma.recipient.findFirstOrThrow({ + where: { + id, + }, + }); + + if (recipient.signingStatus === SigningStatus.SIGNED) { + throw new Error('Cannot update a recipient that has already signed.'); + } + + return await prisma.recipient.update({ + where: { + id, + }, + data: { + name, + email, + }, + }); +}; diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 8f39e3d25..dd427dc95 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -22,12 +22,14 @@ import { sendCompletedEmail } from './send-completed-email'; export type SealDocumentOptions = { documentId: number; sendEmail?: boolean; + isResealing?: boolean; requestMetadata?: RequestMetadata; }; export const sealDocument = async ({ documentId, sendEmail = true, + isResealing = false, requestMetadata, }: SealDocumentOptions) => { 'use server'; @@ -78,11 +80,20 @@ export const sealDocument = async ({ throw new Error(`Document ${document.id} has unsigned fields`); } + if (isResealing) { + // If we're resealing we want to use the initial data for the document + // so we aren't placing fields on top of eachother. + documentData.data = documentData.initialData; + } + // !: Need to write the fields onto the document as a hard copy const pdfData = await getFile(documentData); const doc = await PDFDocument.load(pdfData); + // Flatten the form to stop annotation layers from appearing above documenso fields + doc.getForm().flatten(); + for (const field of fields) { await insertFieldInPDF(doc, field); } @@ -134,7 +145,7 @@ export const sealDocument = async ({ }); }); - if (sendEmail) { + if (sendEmail && !isResealing) { await sendCompletedEmail({ documentId, requestMetadata }); } diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index d6d4284b4..71f579c26 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -4,20 +4,18 @@ import { DocumentStatus } from '@documenso/prisma/client'; import { deletedAccountServiceAccount } from './service-accounts/deleted-account'; export type DeleteUserOptions = { - email: string; + id: number; }; -export const deleteUser = async ({ email }: DeleteUserOptions) => { +export const deleteUser = async ({ id }: DeleteUserOptions) => { const user = await prisma.user.findFirst({ where: { - email: { - contains: email, - }, + id, }, }); if (!user) { - throw new Error(`User with email ${email} not found`); + throw new Error(`User with ID ${id} not found`); } const serviceAccount = await deletedAccountServiceAccount(); diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7d71ab346..5be3ad9db 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,14 +1,39 @@ import { TRPCError } from '@trpc/server'; +import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; +import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient'; import { updateUser } from '@documenso/lib/server-only/admin/update-user'; +import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting'; +import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; +import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { adminProcedure, router } from '../trpc'; -import { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema'; +import { + ZAdminDeleteUserMutationSchema, + ZAdminFindDocumentsQuerySchema, + ZAdminResealDocumentMutationSchema, + ZAdminUpdateProfileMutationSchema, + ZAdminUpdateRecipientMutationSchema, + ZAdminUpdateSiteSettingMutationSchema, +} from './schema'; export const adminRouter = router({ + findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => { + const { term, page, perPage } = input; + + try { + return await findDocuments({ term, page, perPage }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to retrieve the documents. Please try again.', + }); + } + }), + updateUser: adminProcedure - .input(ZUpdateProfileMutationByAdminSchema) + .input(ZAdminUpdateProfileMutationSchema) .mutation(async ({ input }) => { const { id, name, email, roles } = input; @@ -22,8 +47,23 @@ export const adminRouter = router({ } }), + updateRecipient: adminProcedure + .input(ZAdminUpdateRecipientMutationSchema) + .mutation(async ({ input }) => { + const { id, name, email } = input; + + try { + return await updateRecipient({ id, name, email }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to update the recipient provided.', + }); + } + }), + updateSiteSetting: adminProcedure - .input(ZUpdateSiteSettingMutationSchema) + .input(ZAdminUpdateSiteSettingMutationSchema) .mutation(async ({ ctx, input }) => { try { const { id, enabled, data } = input; @@ -41,4 +81,41 @@ export const adminRouter = router({ }); } }), + + resealDocument: adminProcedure + .input(ZAdminResealDocumentMutationSchema) + .mutation(async ({ input }) => { + const { id } = input; + + try { + return await sealDocument({ documentId: id, isResealing: true }); + } catch (err) { + console.log('resealDocument error', err); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to reseal the document provided.', + }); + } + }), + + deleteUser: adminProcedure.input(ZAdminDeleteUserMutationSchema).mutation(async ({ input }) => { + const { id, email } = input; + + try { + const user = await getUserById({ id }); + + if (user.email !== email) { + throw new Error('Email does not match'); + } + + return await deleteUser({ id }); + } catch (err) { + console.log(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to delete the specified account. Please try again.', + }); + } + }), }); diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts index 0b99c8372..cfedb06ba 100644 --- a/packages/trpc/server/admin-router/schema.ts +++ b/packages/trpc/server/admin-router/schema.ts @@ -3,17 +3,48 @@ import z from 'zod'; import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema'; -export const ZUpdateProfileMutationByAdminSchema = z.object({ +export const ZAdminFindDocumentsQuerySchema = z.object({ + term: z.string().optional(), + page: z.number().optional().default(1), + perPage: z.number().optional().default(20), +}); + +export type TAdminFindDocumentsQuerySchema = z.infer; + +export const ZAdminUpdateProfileMutationSchema = z.object({ id: z.number().min(1), name: z.string().nullish(), email: z.string().email().optional(), roles: z.array(z.nativeEnum(Role)).optional(), }); -export type TUpdateProfileMutationByAdminSchema = z.infer< - typeof ZUpdateProfileMutationByAdminSchema +export type TAdminUpdateProfileMutationSchema = z.infer; + +export const ZAdminUpdateRecipientMutationSchema = z.object({ + id: z.number().min(1), + name: z.string().optional(), + email: z.string().email().optional(), +}); + +export type TAdminUpdateRecipientMutationSchema = z.infer< + typeof ZAdminUpdateRecipientMutationSchema >; -export const ZUpdateSiteSettingMutationSchema = ZSiteSettingSchema; +export const ZAdminUpdateSiteSettingMutationSchema = ZSiteSettingSchema; -export type TUpdateSiteSettingMutationSchema = z.infer; +export type TAdminUpdateSiteSettingMutationSchema = z.infer< + typeof ZAdminUpdateSiteSettingMutationSchema +>; + +export const ZAdminResealDocumentMutationSchema = z.object({ + id: z.number().min(1), +}); + +export type TAdminResealDocumentMutationSchema = z.infer; + +export const ZAdminDeleteUserMutationSchema = z.object({ + id: z.number().min(1), + email: z.string().email(), +}); + +export type TAdminDeleteUserMutationSchema = z.infer; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index f9f409aa6..542ac2807 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -207,9 +207,9 @@ export const profileRouter = router({ deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { try { - const user = ctx.user; - - return await deleteUser(user); + return await deleteUser({ + id: ctx.user.id, + }); } catch (err) { let message = 'We were unable to delete your account. Please try again.';