Compare commits
16 Commits
v1.5.0-rc.
...
v1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17d4a2a39 | ||
|
|
73aae6f1e3 | ||
|
|
328d16483c | ||
|
|
6dd2abfe51 | ||
|
|
452545dab1 | ||
|
|
437410c73a | ||
|
|
36a95f6153 | ||
|
|
0f03ad4a6b | ||
|
|
8674ad4c88 | ||
|
|
00c36782ff | ||
|
|
665ccd7628 | ||
|
|
e5fe3d897d | ||
|
|
bfb1c65f98 | ||
|
|
3b8b87a90b | ||
|
|
819e58dd61 | ||
|
|
8c435d48b7 |
@@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
|||||||
|
|
||||||
To run Documenso locally, you will need
|
To run Documenso locally, you will need
|
||||||
|
|
||||||
- Node.js
|
- Node.js (v18 or above)
|
||||||
- Postgres SQL Database
|
- Postgres SQL Database
|
||||||
- Docker (optional)
|
- Docker (optional)
|
||||||
|
|
||||||
|
|||||||
61
apps/marketing/content/blog/launch-week-2-day-5.mdx
Normal file
61
apps/marketing/content/blog/launch-week-2-day-5.mdx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
title: Launch Week II - Day 5 - Documenso Profiles
|
||||||
|
description: Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles will launch as soon as they are shiny.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2024-03-01
|
||||||
|
tags:
|
||||||
|
- Launch Week
|
||||||
|
- Profiles
|
||||||
|
---
|
||||||
|
|
||||||
|
<video
|
||||||
|
id="vid"
|
||||||
|
width="100%"
|
||||||
|
src="https://github.com/documenso/design/assets/1309312/89643ae2-2aa9-484c-a522-a0e35097c469"
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
></video>
|
||||||
|
|
||||||
|
> TLDR; Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles launch as soon as they are shiny.
|
||||||
|
|
||||||
|
## Introducing Documenso Profile Links
|
||||||
|
|
||||||
|
Day 5 - The Finale 🔥
|
||||||
|
|
||||||
|
Signing documents has always been between humans, and signing something together should be as frictionless as possible. It should also be async, so you don't force your counterpart to jog to their device to send something when you are ready. Today we are announcing the new Documenso Profiles:
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/profile.png"
|
||||||
|
width="1260"
|
||||||
|
height="813"
|
||||||
|
alt="Timur's Documenso Profile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
Async > Sync: Add public templates to your Documenso Link and let people sign whenever they are ready.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
Documenso profiles work with your existing templates. You can just add them to your public profile to let everyone with your link sign them. With profiles, we want to bring back the human aspect of signing.
|
||||||
|
|
||||||
|
By making profiles public, you can always access what your counterparty offers and make them more visible in the process. Long-term, we plan to add more to profiles to help you ensure the person you are dealing with is who they claim to be. Documenso wants to be the trust layer of the internet, and we want to start at the very fundamental level: The individual transaction.
|
||||||
|
|
||||||
|
Profiles are our first step towards bringing more trust into everything, simply by making the use of signing more frictionless. As there is more and more content of questionable origin out there, we want to support you in making it clear what you send out and what not.
|
||||||
|
|
||||||
|
## Pricing and Claiming
|
||||||
|
|
||||||
|
Documenso profile username can be claimed starting today. Documenso profiles will launch as soon as we are happy with the details ✨
|
||||||
|
|
||||||
|
- Long usernames (6 characters or more) come free with every account, e.g. **documenso.com/u/timurercan**
|
||||||
|
- Short usernames (5 characters or fewer) or less require any paid account ([Early Adopter](https://documen.so/claim-early-adopters-plan), [Teams](https://documen.so/teams) or Enterprise): **e.g., documenso.com/u/timur**
|
||||||
|
|
||||||
|
You can claim your username here: [https://documen.so/claim](https://documen.so/claim)
|
||||||
|
|
||||||
|
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 5! You can now claim your username for the upcoming profile links 😮 https://documen.so/day5"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
|
||||||
|
|
||||||
|
Best from Hamburg\
|
||||||
|
Timur
|
||||||
BIN
apps/marketing/public/blog/profile.png
Normal file
BIN
apps/marketing/public/blog/profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 365 KiB |
@@ -56,7 +56,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-background text-center text-sm">
|
<div className="text-background text-center text-sm text-white">
|
||||||
Claim your documenso public profile username now!{' '}
|
Claim your documenso public profile username now!{' '}
|
||||||
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
|
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
|
||||||
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
|
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -27,15 +27,16 @@ export const ClaimProfileAlertDialog = ({ className, user }: ClaimProfileAlertDi
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<AlertTitle>Claim your profile</AlertTitle>
|
<AlertTitle>{user.url ? 'Update your profile' : 'Claim your profile'}</AlertTitle>
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
Profiles are coming soon! Claim your profile username now to reserve your corner of the
|
{user.url
|
||||||
signing revolution.
|
? 'Profiles are coming soon! Update your profile username to reserve your corner of the signing revolution.'
|
||||||
|
: 'Profiles are coming soon! Claim your profile username now to reserve your corner of the signing revolution.'}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Button onClick={() => setOpen(true)}>Claim Now</Button>
|
<Button onClick={() => setOpen(true)}>{user.url ? 'Update Now' : 'Claim Now'}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -119,8 +119,6 @@ export const SignUpFormV2 = ({
|
|||||||
form.formState.dirtyFields.signature &&
|
form.formState.dirtyFields.signature &&
|
||||||
form.formState.errors.signature === undefined;
|
form.formState.errors.signature === undefined;
|
||||||
|
|
||||||
console.log({ formSTate: form.formState });
|
|
||||||
|
|
||||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
|
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormV2Schema) => {
|
||||||
@@ -421,8 +419,7 @@ export const SignUpFormV2 = ({
|
|||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={step === 'BASIC_DETAILS'}
|
disabled={step === 'BASIC_DETAILS' || form.formState.isSubmitting}
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
onClick={() => setStep('BASIC_DETAILS')}
|
onClick={() => setStep('BASIC_DETAILS')}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib';
|
||||||
|
|
||||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
@@ -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,43 @@ 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);
|
||||||
|
|
||||||
|
const form = doc.getForm();
|
||||||
|
|
||||||
|
// Remove old signatures
|
||||||
|
for (const field of form.getFields()) {
|
||||||
|
if (field instanceof PDFSignature) {
|
||||||
|
field.acroField.getWidgets().forEach((widget) => {
|
||||||
|
widget.ensureAP();
|
||||||
|
|
||||||
|
try {
|
||||||
|
widget.getNormalAppearance();
|
||||||
|
} catch (e) {
|
||||||
|
const { context } = widget.dict;
|
||||||
|
|
||||||
|
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
|
||||||
|
|
||||||
|
const streamRef = context.register(xobj);
|
||||||
|
|
||||||
|
widget.setNormalAppearance(streamRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten the form to stop annotation layers from appearing above documenso fields
|
||||||
|
form.flatten();
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
await insertFieldInPDF(doc, field);
|
await insertFieldInPDF(doc, field);
|
||||||
}
|
}
|
||||||
@@ -134,7 +168,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,5 +1,13 @@
|
|||||||
import signer from 'node-signpdf';
|
import signer from 'node-signpdf';
|
||||||
import { PDFArray, PDFDocument, PDFHexString, PDFName, PDFNumber, PDFString } from 'pdf-lib';
|
import {
|
||||||
|
PDFArray,
|
||||||
|
PDFDocument,
|
||||||
|
PDFHexString,
|
||||||
|
PDFName,
|
||||||
|
PDFNumber,
|
||||||
|
PDFString,
|
||||||
|
rectangle,
|
||||||
|
} from 'pdf-lib';
|
||||||
|
|
||||||
export type AddSigningPlaceholderOptions = {
|
export type AddSigningPlaceholderOptions = {
|
||||||
pdf: Buffer;
|
pdf: Buffer;
|
||||||
@@ -39,6 +47,12 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption
|
|||||||
P: pages[0].ref,
|
P: pages[0].ref,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const xobj = widget.context.formXObject([rectangle(0, 0, 0, 0)]);
|
||||||
|
|
||||||
|
const streamRef = widget.context.register(xobj);
|
||||||
|
|
||||||
|
widget.set(PDFName.of('AP'), widget.context.obj({ N: streamRef }));
|
||||||
|
|
||||||
const widgetRef = doc.context.register(widget);
|
const widgetRef = doc.context.register(widget);
|
||||||
|
|
||||||
let widgets = pages[0].node.get(PDFName.of('Annots'));
|
let widgets = pages[0].node.get(PDFName.of('Annots'));
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const authRouter = router({
|
|||||||
|
|
||||||
const { name, email, password, signature, url } = input;
|
const { name, email, password, signature, url } = input;
|
||||||
|
|
||||||
if ((true || IS_BILLING_ENABLED()) && url && url.length <= 6) {
|
if (IS_BILLING_ENABLED() && url && url.length < 6) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
AppErrorCode.PREMIUM_PROFILE_URL,
|
AppErrorCode.PREMIUM_PROFILE_URL,
|
||||||
'Only subscribers can have a username shorter than 6 characters',
|
'Only subscribers can have a username shorter than 6 characters',
|
||||||
|
|||||||
@@ -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