feat: improve admin panel

This commit is contained in:
Mythie
2024-03-03 01:55:33 +11:00
parent 328d16483c
commit 73aae6f1e3
19 changed files with 824 additions and 169 deletions

View File

@ -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 (
<div className={cn('flex gap-x-4', className)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.status !== DocumentStatus.COMPLETED}
onClick={() => resealDocument({ id: document.id })}
>
Reseal document
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="outline" asChild>
<Link href={`/admin/users/${document.userId}`}>Go to owner</Link>
</Button>
</div>
);
};

View File

@ -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 (
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<DocumentStatus status={document.status} />
</div>
{document.deletedAt && (
<Badge size="large" variant="destructive">
Deleted
</Badge>
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div>
Created on: <LocaleDate date={document.createdAt} format={DateTime.DATETIME_MED} />
</div>
<div>
Last updated at: <LocaleDate date={document.updatedAt} format={DateTime.DATETIME_MED} />
</div>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">Admin Actions</h2>
<AdminActions className="mt-2" document={document} />
<hr className="my-4" />
<h2 className="text-lg font-semibold">Recipients</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{document.Recipient.map((recipient) => (
<AccordionItem
key={recipient.id}
value={recipient.id.toString()}
className="rounded-lg border"
>
<AccordionTrigger className="px-4">
<div className="flex items-center gap-x-4">
<h4 className="font-semibold">{recipient.name}</h4>
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<RecipientItem recipient={recipient} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
);
}

View File

@ -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<typeof ZAdminUpdateRecipientFormSchema>;
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<TAdminUpdateRecipientFormSchema>({
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 (
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onUpdateRecipientFormSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-4"
disabled={
form.formState.isSubmitting || recipient.signingStatus === SigningStatus.SIGNED
}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button type="submit" loading={form.formState.isSubmitting}>
Update Recipient
</Button>
</div>
</fieldset>
</form>
</Form>
<hr className="my-4" />
<h2 className="mb-4 text-lg font-semibold">Fields</h2>
<DataTable
data={recipient.Field}
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <div>{row.original.type}</div>,
},
{
header: 'Inserted',
accessorKey: 'inserted',
cell: ({ row }) => <div>{row.original.inserted ? 'True' : 'False'}</div>,
},
{
header: 'Value',
accessorKey: 'customText',
cell: ({ row }) => <div>{row.original.customText}</div>,
},
{
header: 'Signature',
accessorKey: 'signature',
cell: ({ row }) => (
<div>
{row.original.Signature?.typedSignature && (
<span>{row.original.Signature.typedSignature}</span>
)}
{row.original.Signature?.signatureImageAsBase64 && (
<img
src={row.original.Signature.signatureImageAsBase64}
alt="Signature"
className="h-12 w-full dark:invert"
/>
)}
</div>
),
},
]}
/>
</div>
);
};

View File

@ -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<User, 'id' | 'name' | 'email'>;
}
>;
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return (
<div className="block max-w-[5rem] truncate font-medium md:max-w-[10rem]">
{row.original.title}
</div>
);
},
},
{
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 (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.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,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 (
<div>
<Input
type="search"
placeholder="Search by document title"
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
<div className="relative mt-4">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return (
<Link
href={`/admin/documents/${row.original.id}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}
</Link>
);
},
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
{
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 (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
]}
data={findDocumentsData?.data ?? []}
perPage={findDocumentsData?.perPage ?? 20}
currentPage={findDocumentsData?.currentPage ?? 1}
totalPages={findDocumentsData?.totalPages ?? 1}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isFindDocumentsLoading && (
<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>
</div>
);
};

View File

@ -1,28 +1,12 @@
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; import { AdminDocumentResults } from './document-results';
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,
});
export default function AdminDocumentsPage() {
return ( return (
<div> <div>
<h2 className="text-4xl font-semibold">Manage documents</h2> <h2 className="text-4xl font-semibold">Manage documents</h2>
<div className="mt-8"> <div className="mt-8">
<DocumentsDataTable results={results} /> <AdminDocumentResults />
</div> </div>
</div> </div>
); );

View File

@ -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 (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
variant="neutral"
>
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertDescription className="mr-2">
Delete the users account and all its contents. This action is irreversible and will
cancel their subscription, so proceed with caution.
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>Delete Account</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain.
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>
To confirm, please enter the accounts email address <br />({user.email}).
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<DialogFooter>
<Button
onClick={onDeleteAccount}
loading={isDeletingUser}
variant="destructive"
disabled={email !== user.email}
>
{isDeletingUser ? 'Deleting account...' : 'Delete Account'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};

View File

@ -7,7 +7,7 @@ import { useForm } from 'react-hook-form';
import type { z } from 'zod'; import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react'; 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 { Button } from '@documenso/ui/primitives/button';
import { import {
Form, Form,
@ -20,9 +20,10 @@ import {
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { DeleteUserDialog } from './delete-user-dialog';
import { MultiSelectRoleCombobox } from './multiselect-role-combobox'; import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>; type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
@ -137,6 +138,10 @@ export default function UserPage({ params }: { params: { id: number } }) {
</fieldset> </fieldset>
</form> </form>
</Form> </Form>
<hr className="my-4" />
{user && <DeleteUserDialog user={user} />}
</div> </div>
); );
} }

View File

@ -19,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const [{ users, totalPages }, individualPrices] = await Promise.all([ const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage), search(searchString, page, perPage),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY), getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY).catch(() => []),
]); ]);
const individualPriceIds = individualPrices.map((price) => price.id); const individualPriceIds = individualPrices.map((price) => price.id);

View File

@ -57,7 +57,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
key={href} key={href}
href={`${rootHref}${href}`} href={`${rootHref}${href}`}
className={cn( 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( 'text-foreground dark:text-muted-foreground': pathname?.startsWith(
`${rootHref}${href}`, `${rootHref}${href}`,

View File

@ -86,7 +86,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
> >
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8"> <div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
<Link <Link
href={getRootHref(params)} href={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline" className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
> >
<Logo className="h-6 w-auto" /> <Logo className="h-6 w-auto" />

View File

@ -1,5 +1,5 @@
import { trpc } from '@documenso/trpc/react'; 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'; import { useCopyToClipboard } from './use-copy-to-clipboard';

View File

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

View File

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

View File

@ -22,12 +22,14 @@ import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = { export type SealDocumentOptions = {
documentId: number; documentId: number;
sendEmail?: boolean; sendEmail?: boolean;
isResealing?: boolean;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
export const sealDocument = async ({ export const sealDocument = async ({
documentId, documentId,
sendEmail = true, sendEmail = true,
isResealing = false,
requestMetadata, requestMetadata,
}: SealDocumentOptions) => { }: SealDocumentOptions) => {
'use server'; 'use server';
@ -78,11 +80,20 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has unsigned fields`); 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 // !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData); const pdfData = await getFile(documentData);
const doc = await PDFDocument.load(pdfData); 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) { for (const field of fields) {
await insertFieldInPDF(doc, field); await insertFieldInPDF(doc, field);
} }
@ -134,7 +145,7 @@ export const sealDocument = async ({
}); });
}); });
if (sendEmail) { if (sendEmail && !isResealing) {
await sendCompletedEmail({ documentId, requestMetadata }); await sendCompletedEmail({ documentId, requestMetadata });
} }

View File

@ -4,20 +4,18 @@ import { DocumentStatus } from '@documenso/prisma/client';
import { deletedAccountServiceAccount } from './service-accounts/deleted-account'; import { deletedAccountServiceAccount } from './service-accounts/deleted-account';
export type DeleteUserOptions = { 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({ const user = await prisma.user.findFirst({
where: { where: {
email: { id,
contains: email,
},
}, },
}); });
if (!user) { if (!user) {
throw new Error(`User with email ${email} not found`); throw new Error(`User with ID ${id} not found`);
} }
const serviceAccount = await deletedAccountServiceAccount(); const serviceAccount = await deletedAccountServiceAccount();

View File

@ -1,14 +1,39 @@
import { TRPCError } from '@trpc/server'; 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 { 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 { 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 { adminProcedure, router } from '../trpc';
import { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema'; import {
ZAdminDeleteUserMutationSchema,
ZAdminFindDocumentsQuerySchema,
ZAdminResealDocumentMutationSchema,
ZAdminUpdateProfileMutationSchema,
ZAdminUpdateRecipientMutationSchema,
ZAdminUpdateSiteSettingMutationSchema,
} from './schema';
export const adminRouter = router({ 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 updateUser: adminProcedure
.input(ZUpdateProfileMutationByAdminSchema) .input(ZAdminUpdateProfileMutationSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { id, name, email, roles } = 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 updateSiteSetting: adminProcedure
.input(ZUpdateSiteSettingMutationSchema) .input(ZAdminUpdateSiteSettingMutationSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
const { id, enabled, data } = input; 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.',
});
}
}),
}); });

View File

@ -3,17 +3,48 @@ import z from 'zod';
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema'; 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<typeof ZAdminFindDocumentsQuerySchema>;
export const ZAdminUpdateProfileMutationSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
name: z.string().nullish(), name: z.string().nullish(),
email: z.string().email().optional(), email: z.string().email().optional(),
roles: z.array(z.nativeEnum(Role)).optional(), roles: z.array(z.nativeEnum(Role)).optional(),
}); });
export type TUpdateProfileMutationByAdminSchema = z.infer< export type TAdminUpdateProfileMutationSchema = z.infer<typeof ZAdminUpdateProfileMutationSchema>;
typeof ZUpdateProfileMutationByAdminSchema
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<typeof ZUpdateSiteSettingMutationSchema>; export type TAdminUpdateSiteSettingMutationSchema = z.infer<
typeof ZAdminUpdateSiteSettingMutationSchema
>;
export const ZAdminResealDocumentMutationSchema = z.object({
id: z.number().min(1),
});
export type TAdminResealDocumentMutationSchema = z.infer<typeof ZAdminResealDocumentMutationSchema>;
export const ZAdminDeleteUserMutationSchema = z.object({
id: z.number().min(1),
email: z.string().email(),
});
export type TAdminDeleteUserMutationSchema = z.infer<typeof ZAdminDeleteUserMutationSchema>;

View File

@ -207,9 +207,9 @@ export const profileRouter = router({
deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => {
try { try {
const user = ctx.user; return await deleteUser({
id: ctx.user.id,
return await deleteUser(user); });
} catch (err) { } catch (err) {
let message = 'We were unable to delete your account. Please try again.'; let message = 'We were unable to delete your account. Please try again.';