mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: improve admin panel
This commit is contained in:
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
86
apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx
Normal file
86
apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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}`,
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
26
packages/lib/server-only/admin/get-entire-document.ts
Normal file
26
packages/lib/server-only/admin/get-entire-document.ts
Normal 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;
|
||||||
|
};
|
||||||
30
packages/lib/server-only/admin/update-recipient.ts
Normal file
30
packages/lib/server-only/admin/update-recipient.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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.';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user