mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
Merge branch 'main' into custom-text-field
This commit is contained in:
@ -5,11 +5,10 @@ import { allDocuments } from 'contentlayer/generated';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
export const generateStaticParams = () =>
|
||||
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
||||
const document = allDocuments.find((post) => post._raw.flattenedPath === params.content);
|
||||
const document = allDocuments.find((doc) => doc._raw.flattenedPath === params.content);
|
||||
|
||||
if (!document) {
|
||||
return { title: 'Not Found' };
|
||||
|
||||
@ -9,9 +9,6 @@ import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const generateStaticParams = () =>
|
||||
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
|
||||
|
||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { allBlogPosts } from 'contentlayer/generated';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
};
|
||||
|
||||
export default function BlogPage() {
|
||||
const blogPosts = allBlogPosts.sort((a, b) => {
|
||||
const dateA = new Date(a.date);
|
||||
|
||||
@ -2,6 +2,8 @@ import { Suspense } from 'react';
|
||||
|
||||
import { Caveat, Inter } from 'next/font/google';
|
||||
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
|
||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
||||
@ -62,6 +64,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
|
||||
<Suspense>
|
||||
|
||||
@ -114,7 +114,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
</p>
|
||||
|
||||
<Button className="mt-6 rounded-full text-base" asChild>
|
||||
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}>Signup Now</Link>
|
||||
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank">
|
||||
Signup Now
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-8 flex w-full flex-col divide-y">
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"remeda": "^1.27.1",
|
||||
"sharp": "0.33.1",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
|
||||
import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -78,6 +78,20 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
Subscriptions
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/banner') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/site-settings">
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
Site Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
200
apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
Normal file
200
apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import {
|
||||
SITE_SETTINGS_BANNER_ID,
|
||||
ZSiteSettingsBannerSchema,
|
||||
} from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
||||
|
||||
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
|
||||
|
||||
export type BannerFormProps = {
|
||||
banner?: TSiteSettingsBannerSchema;
|
||||
};
|
||||
|
||||
export function BannerForm({ banner }: BannerFormProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TBannerFormSchema>({
|
||||
resolver: zodResolver(ZBannerFormSchema),
|
||||
defaultValues: {
|
||||
id: SITE_SETTINGS_BANNER_ID,
|
||||
enabled: banner?.enabled ?? false,
|
||||
data: {
|
||||
content: banner?.data?.content ?? '',
|
||||
bgColor: banner?.data?.bgColor ?? '#000000',
|
||||
textColor: banner?.data?.textColor ?? '#FFFFFF',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = form.watch('enabled');
|
||||
|
||||
const { mutateAsync: updateSiteSetting, isLoading: isUpdateSiteSettingLoading } =
|
||||
trpcReact.admin.updateSiteSetting.useMutation();
|
||||
|
||||
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
|
||||
try {
|
||||
await updateSiteSetting({
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Banner Updated',
|
||||
description: 'Your banner has been updated successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} 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:
|
||||
'We encountered an unknown error while attempting to update the banner. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-semibold">Site Banner</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
The site banner is a message that is shown at the top of the site. It can be used to display
|
||||
important information to your users.
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-4 flex flex-col rounded-md"
|
||||
onSubmit={form.handleSubmit(onBannerUpdate)}
|
||||
>
|
||||
<div className="mt-4 flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<fieldset
|
||||
className="flex flex-col gap-4 md:flex-row"
|
||||
disabled={!enabled}
|
||||
aria-disabled={!enabled}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.bgColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Background Color</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.textColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Text Color</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!enabled} aria-disabled={!enabled}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="h-32 resize-none" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
The content to show in the banner, HTML is allowed
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isUpdateSiteSettingLoading}
|
||||
className="mt-4 justify-end self-end"
|
||||
>
|
||||
Update Banner
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/app/(dashboard)/admin/site-settings/page.tsx
Normal file
24
apps/web/src/app/(dashboard)/admin/site-settings/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
|
||||
import { BannerForm } from './banner-form';
|
||||
|
||||
// import { BannerForm } from './banner-form';
|
||||
|
||||
export default async function AdminBannerPage() {
|
||||
const banner = await getSiteSettings().then((settings) =>
|
||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
|
||||
|
||||
<div className="mt-8">
|
||||
<BannerForm banner={banner} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentPageViewButtonProps = {
|
||||
document: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isRecipient = !!recipient;
|
||||
const isPending = document.status === DocumentStatus.PENDING;
|
||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const role = recipient?.role;
|
||||
|
||||
const documentsPath = formatDocumentsPath(document.team?.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.getDocumentById.query({
|
||||
id: document.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
const documentData = documentWithData?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
throw new Error('No document available');
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: documentWithData.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'An error occurred while downloading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return match({
|
||||
isRecipient,
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
})
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-full" asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<>
|
||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
View
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: false }, () => (
|
||||
<Button className="w-full" asChild>
|
||||
<Link href={`${documentsPath}/${document.id}/edit`}>Edit</Link>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: true }, () => (
|
||||
<Button className="w-full" onClick={onDownloadClick}>
|
||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => null);
|
||||
};
|
||||
@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ResendDocumentActionItem } from '../_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from '../delete-document-dialog';
|
||||
import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
|
||||
|
||||
export type DocumentPageViewDropdownProps = {
|
||||
document: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipient = document.Recipient.find((recipient) => recipient.email === session.user.email);
|
||||
|
||||
const isOwner = document.User.id === session.user.id;
|
||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||
const isDocumentDeletable = isOwner;
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.getDocumentById.query({
|
||||
id: document.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
const documentData = documentWithData?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: document.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'An error occurred while downloading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nonSignedRecipients = document.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="end" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
{(isOwner || isCurrentTeamDocument) && !isComplete && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`${documentsPath}/${document.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{isComplete && (
|
||||
<DropdownMenuItem onClick={onDownloadClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||
|
||||
<ResendDocumentActionItem
|
||||
document={document}
|
||||
recipients={nonSignedRecipients}
|
||||
team={team}
|
||||
/>
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
token={isOwner ? undefined : recipient?.token}
|
||||
trigger={({ loading, disabled }) => (
|
||||
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||
<div className="flex items-center">
|
||||
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
||||
Share Signing Card
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
{isDocumentDeletable && (
|
||||
<DeleteDocumentDialog
|
||||
id={document.id}
|
||||
status={document.status}
|
||||
documentTitle={document.title}
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
/>
|
||||
)}
|
||||
{isDuplicateDialogOpen && (
|
||||
<DuplicateDocumentDialog
|
||||
id={document.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
|
||||
export type DocumentPageViewInformationProps = {
|
||||
userId: number;
|
||||
document: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
};
|
||||
|
||||
export const DocumentPageViewInformation = ({
|
||||
document,
|
||||
userId,
|
||||
}: DocumentPageViewInformationProps) => {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const { locale } = useLocale();
|
||||
|
||||
const documentInformation = useMemo(() => {
|
||||
let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy');
|
||||
let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative();
|
||||
|
||||
if (!isMounted) {
|
||||
createdValue = DateTime.fromJSDate(document.createdAt)
|
||||
.setLocale(locale)
|
||||
.toFormat('MMMM d, yyyy');
|
||||
|
||||
lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative();
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
description: 'Uploaded by',
|
||||
value: userId === document.userId ? 'You' : document.User.name ?? document.User.email,
|
||||
},
|
||||
{
|
||||
description: 'Created',
|
||||
value: createdValue,
|
||||
},
|
||||
{
|
||||
description: 'Last modified',
|
||||
value: lastModifiedValue,
|
||||
},
|
||||
];
|
||||
}, [isMounted, document, locale, userId]);
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
|
||||
<h1 className="px-4 py-3 font-medium">Information</h1>
|
||||
|
||||
<ul className="divide-y border-t">
|
||||
{documentInformation.map((item) => (
|
||||
<li
|
||||
key={item.description}
|
||||
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
|
||||
>
|
||||
<span className="text-muted-foreground">{item.description}</span>
|
||||
<span>{item.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { CheckCheckIcon, CheckIcon, Loader, MailOpen } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type DocumentPageViewRecentActivityProps = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const DocumentPageViewRecentActivity = ({
|
||||
documentId,
|
||||
userId,
|
||||
}: DocumentPageViewRecentActivityProps) => {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isLoadingError,
|
||||
refetch,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
|
||||
{
|
||||
documentId,
|
||||
filterForRecentActivity: true,
|
||||
orderBy: {
|
||||
column: 'createdAt',
|
||||
direction: 'asc',
|
||||
},
|
||||
perPage: 10,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
},
|
||||
);
|
||||
|
||||
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">Recent activity</h1>
|
||||
|
||||
{/* Can add dropdown menu here for additional options. */}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-full items-center justify-center py-16">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingError && (
|
||||
<div className="flex h-full flex-col items-center justify-center py-16">
|
||||
<p className="text-foreground/80 text-sm">Unable to load document history</p>
|
||||
<button
|
||||
onClick={async () => refetch()}
|
||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||
>
|
||||
Click here to retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimateGenericFadeInOut>
|
||||
{data && (
|
||||
<ul role="list" className="space-y-6 p-4">
|
||||
{hasNextPage && (
|
||||
<li className="relative flex gap-x-4">
|
||||
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
|
||||
<div className="bg-border w-px" />
|
||||
</div>
|
||||
|
||||
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
|
||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={async () => fetchNextPage()}
|
||||
className="text-foreground/70 hover:text-muted-foreground text-xs"
|
||||
>
|
||||
{isFetchingNextPage ? 'Loading...' : 'Load older activity'}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{documentAuditLogs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<p className="text-muted-foreground/70 text-sm">No recent activity</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{documentAuditLogs.map((auditLog, auditLogIndex) => (
|
||||
<li key={auditLog.id} className="relative flex gap-x-4">
|
||||
<div
|
||||
className={cn(
|
||||
auditLogIndex === documentAuditLogs.length - 1 ? 'h-6' : '-bottom-6',
|
||||
'absolute left-0 top-0 flex w-6 justify-center',
|
||||
)}
|
||||
>
|
||||
<div className="bg-border w-px" />
|
||||
</div>
|
||||
|
||||
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
|
||||
{match(auditLog.type)
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<CheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<MailOpen className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground dark:text-muted-foreground/70 flex-auto py-0.5 text-xs leading-5">
|
||||
<span className="text-foreground font-medium">
|
||||
{formatDocumentAuditLogAction(auditLog, userId).prefix}
|
||||
</span>{' '}
|
||||
{formatDocumentAuditLogAction(auditLog, userId).description}
|
||||
</p>
|
||||
|
||||
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
||||
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,115 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
|
||||
export type DocumentPageViewRecipientsProps = {
|
||||
document: Document & {
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
documentRootPath: string;
|
||||
};
|
||||
|
||||
export const DocumentPageViewRecipients = ({
|
||||
document,
|
||||
documentRootPath,
|
||||
}: DocumentPageViewRecipientsProps) => {
|
||||
const recipients = document.Recipient;
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<div className="flex flex-row items-center justify-between px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">Recipients</h1>
|
||||
|
||||
{document.status !== DocumentStatus.COMPLETED && (
|
||||
<Link
|
||||
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||
title="Modify recipients"
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
{recipients.length === 0 ? (
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<PenIcon className="ml-2 h-3 w-3" />
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="text-muted-foreground divide-y border-t">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">No recipients</li>
|
||||
)}
|
||||
|
||||
{recipients.map((recipient) => (
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||
<Badge variant="default">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
Approved
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.CC, () =>
|
||||
document.status === DocumentStatus.COMPLETED ? (
|
||||
<>
|
||||
<MailIcon className="mr-1 h-3 w-3" />
|
||||
Sent
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
Ready
|
||||
</>
|
||||
),
|
||||
)
|
||||
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
Signed
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<>
|
||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||
Viewed
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -1,22 +1,34 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
} from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentPageViewButton } from './document-page-view-button';
|
||||
import { DocumentPageViewDropdown } from './document-page-view-dropdown';
|
||||
import { DocumentPageViewInformation } from './document-page-view-information';
|
||||
import { DocumentPageViewRecentActivity } from './document-page-view-recent-activity';
|
||||
import { DocumentPageViewRecipients } from './document-page-view-recipients';
|
||||
|
||||
export type DocumentPageViewProps = {
|
||||
params: {
|
||||
@ -44,6 +56,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
const isDocumentHistoryEnabled = await getServerComponentFlag(
|
||||
'app_document_page_view_history_sheet',
|
||||
);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
@ -67,65 +83,122 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
const recipients = await getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const documentWithRecipients = {
|
||||
...document,
|
||||
Recipient: recipients,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatusComponent
|
||||
inheritColor
|
||||
status={document.status}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDocumentHistoryEnabled && (
|
||||
<div className="self-end">
|
||||
<DocumentHistorySheet documentId={document.id} userId={user.id}>
|
||||
<Button variant="outline">
|
||||
<Clock9 className="mr-1.5 h-4 w-4" />
|
||||
Document history
|
||||
</Button>
|
||||
</DocumentHistorySheet>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={user}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer
|
||||
document={document}
|
||||
key={documentData.id}
|
||||
documentMeta={documentMeta}
|
||||
documentData={documentData}
|
||||
/>
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<div className="space-y-6">
|
||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||
<div className="flex flex-row items-center justify-between px-4">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
|
||||
</h3>
|
||||
|
||||
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 px-4 text-sm ">
|
||||
{match(document.status)
|
||||
.with(
|
||||
DocumentStatus.COMPLETED,
|
||||
() => 'This document has been signed by all recipients',
|
||||
)
|
||||
.with(
|
||||
DocumentStatus.DRAFT,
|
||||
() => 'This document is currently a draft and has not been sent',
|
||||
)
|
||||
.with(DocumentStatus.PENDING, () => {
|
||||
const pendingRecipients = recipients.filter(
|
||||
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
|
||||
);
|
||||
|
||||
return `Waiting on ${pendingRecipients.length} recipient${
|
||||
pendingRecipients.length > 1 ? 's' : ''
|
||||
}`;
|
||||
})
|
||||
.exhaustive()}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 border-t px-4 pt-4">
|
||||
<DocumentPageViewButton document={documentWithRecipients} team={team} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Document information section. */}
|
||||
<DocumentPageViewInformation document={documentWithRecipients} userId={user.id} />
|
||||
|
||||
{/* Recipients section. */}
|
||||
<DocumentPageViewRecipients
|
||||
document={documentWithRecipients}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
|
||||
{/* Recent activity section. */}
|
||||
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,10 +2,16 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type DocumentMeta,
|
||||
DocumentStatus,
|
||||
type Field,
|
||||
type Recipient,
|
||||
type User,
|
||||
} from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -49,12 +55,9 @@ export const EditDocumentForm = ({
|
||||
documentRootPath,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
// controlled stepper state
|
||||
const [step, setStep] = useState<EditDocumentStep>(
|
||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
|
||||
);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||
@ -86,6 +89,24 @@ export const EditDocumentForm = ({
|
||||
},
|
||||
};
|
||||
|
||||
const [step, setStep] = useState<EditDocumentStep>(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
|
||||
|
||||
let initialStep: EditDocumentStep =
|
||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
|
||||
|
||||
if (
|
||||
searchParamStep &&
|
||||
documentFlow[searchParamStep] !== undefined &&
|
||||
!(recipients.length === 0 && (searchParamStep === 'subject' || searchParamStep === 'fields'))
|
||||
) {
|
||||
initialStep = searchParamStep;
|
||||
}
|
||||
|
||||
return initialStep;
|
||||
});
|
||||
|
||||
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
|
||||
@ -0,0 +1,121 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
export type DocumentEditPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const DocumentEditPageView = async ({ params, team }: DocumentEditPageViewProps) => {
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
if (document.status === InternalDocumentStatus.COMPLETED) {
|
||||
redirect(`${documentRootPath}/${documentId}`);
|
||||
}
|
||||
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
if (documentMeta?.password) {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
const securePassword = Buffer.from(
|
||||
symmetricDecrypt({
|
||||
key,
|
||||
data: documentMeta.password,
|
||||
}),
|
||||
).toString('utf-8');
|
||||
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={user}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
documentRootPath={documentRootPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
11
apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx
Normal file
11
apps/web/src/app/(dashboard)/documents/[id]/edit/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { DocumentEditPageView } from './document-edit-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function DocumentEditPage({ params }: DocumentPageProps) {
|
||||
return <DocumentEditPageView params={params} />;
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
export type DocumentLogsDataTableProps = {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
const dateFormat: DateTimeFormatOptions = {
|
||||
...DateTime.DATETIME_SHORT,
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps) => {
|
||||
const parser = new UAParser();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||
trpc.document.findDocumentAuditLogs.useQuery(
|
||||
{
|
||||
documentId,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
header: 'Time',
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
header: 'User',
|
||||
accessorKey: 'name',
|
||||
cell: ({ row }) =>
|
||||
row.original.name || row.original.email ? (
|
||||
<div>
|
||||
{row.original.name && (
|
||||
<p className="truncate" title={row.original.name}>
|
||||
{row.original.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{row.original.email && (
|
||||
<p className="truncate" title={row.original.email}>
|
||||
{row.original.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>N/A</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
accessorKey: 'ipAddress',
|
||||
},
|
||||
{
|
||||
header: 'Browser',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.userAgent) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
parser.setUA(row.original.userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
|
||||
return result.browser.name ?? 'N/A';
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading && isInitialLoading,
|
||||
rows: 3,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-1/2 py-4 pr-4">
|
||||
<div className="ml-2 flex flex-grow flex-col">
|
||||
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
||||
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-10 rounded-full" />
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,150 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Recipient, Team } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { FRIENDLY_STATUS_MAP } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentLogsDataTable } from './document-logs-data-table';
|
||||
|
||||
export type DocumentLogsPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageViewProps) => {
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const [document, recipients] = await Promise.all([
|
||||
getDocumentById({
|
||||
id: documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null),
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
const documentInformation: { description: string; value: string }[] = [
|
||||
{
|
||||
description: 'Document title',
|
||||
value: document.title,
|
||||
},
|
||||
{
|
||||
description: 'Document ID',
|
||||
value: document.id.toString(),
|
||||
},
|
||||
{
|
||||
description: 'Document status',
|
||||
value: FRIENDLY_STATUS_MAP[document.status].label,
|
||||
},
|
||||
{
|
||||
description: 'Created by',
|
||||
value: document.User.name ?? document.User.email,
|
||||
},
|
||||
{
|
||||
description: 'Date created',
|
||||
value: document.createdAt.toISOString(),
|
||||
},
|
||||
{
|
||||
description: 'Last updated',
|
||||
value: document.updatedAt.toISOString(),
|
||||
},
|
||||
{
|
||||
description: 'Time zone',
|
||||
value: document.documentMeta?.timezone ?? 'N/A',
|
||||
},
|
||||
];
|
||||
|
||||
const formatRecipientText = (recipient: Recipient) => {
|
||||
let text = recipient.email;
|
||||
|
||||
if (recipient.name) {
|
||||
text = `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
return `${text} - ${recipient.role}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link
|
||||
href={`${documentRootPath}/${document.id}`}
|
||||
className="flex items-center text-[#7AC455] hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Document
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||
<Button variant="outline" className="mr-2 w-full sm:w-auto">
|
||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
||||
Download certificate
|
||||
</Button>
|
||||
|
||||
<Button className="w-full sm:w-auto">
|
||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||
{documentInformation.map((info, i) => (
|
||||
<div className="text-foreground text-sm" key={i}>
|
||||
<h3 className="font-semibold">{info.description}</h3>
|
||||
<p className="text-muted-foreground">{info.value}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-foreground text-sm">
|
||||
<h3 className="font-semibold">Recipients</h3>
|
||||
<ul className="text-muted-foreground list-inside list-disc">
|
||||
{recipients.map((recipient) => (
|
||||
<li key={`recipient-${recipient.id}`}>
|
||||
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<DocumentLogsDataTable documentId={document.id} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
11
apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx
Normal file
11
apps/web/src/app/(dashboard)/documents/[id]/logs/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { DocumentLogsPageView } from './document-logs-page-view';
|
||||
|
||||
export type DocumentsLogsPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
|
||||
return <DocumentLogsPageView params={params} />;
|
||||
}
|
||||
@ -118,7 +118,7 @@ export const ResendDocumentActionItem = ({
|
||||
|
||||
<DialogContent className="sm:max-w-sm" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<DialogTitle asChild>
|
||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||
() => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
|
||||
@ -142,7 +142,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Link href={`${documentsPath}/${row.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
|
||||
@ -5,16 +5,19 @@ import Link from 'next/link';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||
|
||||
export type DataTableTitleProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
team: Pick<Team, 'url'> | null;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
teamUrl?: string;
|
||||
};
|
||||
|
||||
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
||||
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (!session) {
|
||||
@ -25,14 +28,18 @@ export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
||||
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
const isRecipient = !!recipient;
|
||||
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
||||
|
||||
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
|
||||
|
||||
return match({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
isCurrentTeamDocument,
|
||||
})
|
||||
.with({ isOwner: true }, () => (
|
||||
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
||||
<Link
|
||||
href={`/documents/${row.id}`}
|
||||
href={`${documentsPath}/${row.id}`}
|
||||
title={row.title}
|
||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||
>
|
||||
|
||||
@ -66,7 +66,7 @@ export const DocumentsDataTable = ({
|
||||
},
|
||||
{
|
||||
header: 'Title',
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||
},
|
||||
{
|
||||
id: 'sender',
|
||||
|
||||
@ -47,7 +47,7 @@ export const DuplicateDocumentDialog = ({
|
||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
onSuccess: (newId) => {
|
||||
router.push(`${documentsPath}/${newId}`);
|
||||
router.push(`${documentsPath}/${newId}/edit`);
|
||||
|
||||
toast({
|
||||
title: 'Document Duplicated',
|
||||
|
||||
@ -83,7 +83,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
|
||||
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Banner } from '~/components/(dashboard)/layout/banner';
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||
@ -37,6 +38,8 @@ export default async function AuthenticatedDashboardLayout({
|
||||
<LimitsProvider>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
|
||||
<Banner />
|
||||
|
||||
<Header user={user} teams={teams} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
@ -76,7 +76,7 @@ export const TemplatesDataTable = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push(`${documentRootPath}/${id}`);
|
||||
router.push(`${documentRootPath}/${id}/edit`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import { DocumentEditPageView } from '~/app/(dashboard)/documents/[id]/edit/document-edit-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsDocumentEditPage({ params }: DocumentPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <DocumentEditPageView params={params} team={team} />;
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import { DocumentLogsPageView } from '~/app/(dashboard)/documents/[id]/logs/document-logs-page-view';
|
||||
|
||||
export type TeamDocumentsLogsPageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsDocumentsLogsPage({ params }: TeamDocumentsLogsPageProps) {
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <DocumentLogsPageView params={params} team={team} />;
|
||||
}
|
||||
29
apps/web/src/components/(dashboard)/layout/banner.tsx
Normal file
29
apps/web/src/components/(dashboard)/layout/banner.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
|
||||
export const Banner = async () => {
|
||||
const banner = await getSiteSettings().then((settings) =>
|
||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{banner && banner.enabled && (
|
||||
<div className="mb-2" style={{ background: banner.data.bgColor }}>
|
||||
<div
|
||||
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
|
||||
style={{ color: banner.data.textColor }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Banner
|
||||
// Custom Text
|
||||
// Custom Text with Custom Icon
|
||||
@ -166,22 +166,24 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{teams.map((team) => (
|
||||
<DropdownMenuItem asChild key={team.id}>
|
||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(team.name)}
|
||||
primaryText={team.name}
|
||||
secondaryText={formatSecondaryAvatarText(team)}
|
||||
rightSideComponent={
|
||||
isPathTeamUrl(team.url) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
||||
{teams.map((team) => (
|
||||
<DropdownMenuItem asChild key={team.id}>
|
||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(team.name)}
|
||||
primaryText={team.name}
|
||||
secondaryText={formatSecondaryAvatarText(team)}
|
||||
rightSideComponent={
|
||||
isPathTeamUrl(team.url) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
|
||||
export type DocumentHistorySheetChangesProps = {
|
||||
values: {
|
||||
key: string | React.ReactNode;
|
||||
value: string | React.ReactNode;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const DocumentHistorySheetChanges = ({ values }: DocumentHistorySheetChangesProps) => {
|
||||
return (
|
||||
<Badge
|
||||
className="text-muted-foreground mt-3 block w-full space-y-0.5 text-xs"
|
||||
variant="neutral"
|
||||
>
|
||||
{values.map(({ key, value }, i) => (
|
||||
<p key={typeof key === 'string' ? key : i}>
|
||||
<span>{key}: </span>
|
||||
<span className="font-normal">{value}</span>
|
||||
</p>
|
||||
))}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
318
apps/web/src/components/document/document-history-sheet.tsx
Normal file
318
apps/web/src/components/document/document-history-sheet.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ArrowRightIcon, Loader } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { DocumentHistorySheetChanges } from './document-history-sheet-changes';
|
||||
|
||||
export type DocumentHistorySheetProps = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
isMenuOpen?: boolean;
|
||||
onMenuOpenChange?: (_value: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DocumentHistorySheet = ({
|
||||
documentId,
|
||||
userId,
|
||||
isMenuOpen,
|
||||
onMenuOpenChange,
|
||||
children,
|
||||
}: DocumentHistorySheetProps) => {
|
||||
const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isLoadingError,
|
||||
refetch,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = trpc.document.findDocumentAuditLogs.useInfiniteQuery(
|
||||
{
|
||||
documentId,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
|
||||
|
||||
const extractBrowser = (userAgent?: string | null) => {
|
||||
if (!userAgent) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const parser = new UAParser(userAgent);
|
||||
|
||||
parser.setUA(userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
|
||||
return result.browser.name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the following formatting for a given text:
|
||||
* - Uppercase first lower, lowercase rest
|
||||
* - Replace _ with spaces
|
||||
*
|
||||
* @param text The text to format
|
||||
* @returns The formatted text
|
||||
*/
|
||||
const formatGenericText = (text: string) => {
|
||||
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
{children && <SheetTrigger asChild>{children}</SheetTrigger>}
|
||||
|
||||
<SheetContent
|
||||
sheetClass="backdrop-blur-none"
|
||||
className="flex w-full max-w-[500px] flex-col overflow-y-auto p-0"
|
||||
>
|
||||
<div className="text-foreground px-6 pt-6">
|
||||
<h1 className="text-lg font-medium">Document history</h1>
|
||||
<button
|
||||
className="text-muted-foreground text-sm"
|
||||
onClick={() => setIsUserDetailsVisible(!isUserDetailsVisible)}
|
||||
>
|
||||
{isUserDetailsVisible ? 'Hide' : 'Show'} additional information
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingError && (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<p className="text-foreground/80 text-sm">Unable to load document history</p>
|
||||
<button
|
||||
onClick={async () => refetch()}
|
||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||
>
|
||||
Click here to retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<ul
|
||||
className={cn('divide-y border-t', {
|
||||
'mb-4 border-b': !hasNextPage,
|
||||
})}
|
||||
>
|
||||
{documentAuditLogs.map((auditLog) => (
|
||||
<li className="px-4 py-2.5" key={auditLog.id}>
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="mr-2 h-9 w-9">
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{(auditLog?.email ?? auditLog?.name ?? '?').slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<p className="text-foreground text-xs font-bold">
|
||||
{formatDocumentAuditLogActionString(auditLog, userId)}
|
||||
</p>
|
||||
<p className="text-foreground/50 text-xs">
|
||||
<LocaleDate date={auditLog.createdAt} format="d MMM, yyyy HH:MM a" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{match(auditLog)
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
|
||||
() => null,
|
||||
)
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED },
|
||||
({ data }) => {
|
||||
const values = [
|
||||
{
|
||||
key: 'Email',
|
||||
value: data.recipientEmail,
|
||||
},
|
||||
{
|
||||
key: 'Role',
|
||||
value: formatGenericText(data.recipientRole),
|
||||
},
|
||||
];
|
||||
|
||||
// Insert the name to the start of the array if available.
|
||||
if (data.recipientName) {
|
||||
values.unshift({
|
||||
key: 'Name',
|
||||
value: data.recipientName,
|
||||
});
|
||||
}
|
||||
|
||||
return <DocumentHistorySheetChanges values={values} />;
|
||||
},
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, ({ data }) => {
|
||||
if (data.changes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentHistorySheetChanges
|
||||
values={data.changes.map(({ type, from, to }) => ({
|
||||
key: formatGenericText(type),
|
||||
value: (
|
||||
<span className="inline-flex flex-row items-center">
|
||||
<span>{type === 'ROLE' ? formatGenericText(from) : from}</span>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<span>{type === 'ROLE' ? formatGenericText(to) : to}</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED },
|
||||
({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Field',
|
||||
value: formatGenericText(data.fieldType),
|
||||
},
|
||||
{
|
||||
key: 'Recipient',
|
||||
value: formatGenericText(data.fieldRecipientEmail),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
||||
if (data.changes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentHistorySheetChanges
|
||||
values={data.changes.map((change) => ({
|
||||
key: formatGenericText(change.type),
|
||||
value: change.type === 'PASSWORD' ? '*********' : change.to,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Old',
|
||||
value: data.from,
|
||||
},
|
||||
{
|
||||
key: 'New',
|
||||
value: data.to,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Field inserted',
|
||||
value: formatGenericText(data.field.type),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Field uninserted',
|
||||
value: formatGenericText(data.field),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Type',
|
||||
value: DOCUMENT_AUDIT_LOG_EMAIL_FORMAT[data.emailType].description,
|
||||
},
|
||||
{
|
||||
key: 'Sent to',
|
||||
value: data.recipientEmail,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.exhaustive()}
|
||||
|
||||
{isUserDetailsVisible && (
|
||||
<>
|
||||
<div className="mb-1 mt-2 flex flex-row space-x-2">
|
||||
<Badge variant="neutral" className="text-muted-foreground">
|
||||
IP: {auditLog.ipAddress ?? 'Unknown'}
|
||||
</Badge>
|
||||
|
||||
<Badge variant="neutral" className="text-muted-foreground">
|
||||
Browser: {extractBrowser(auditLog.userAgent)}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={async () => fetchNextPage()}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@ -13,7 +13,7 @@ type FriendlyStatus = {
|
||||
color: string;
|
||||
};
|
||||
|
||||
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||
PENDING: {
|
||||
label: 'Pending',
|
||||
icon: Clock,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
@ -10,7 +10,7 @@ import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||
|
||||
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
date: string | number | Date;
|
||||
format?: DateTimeFormatOptions;
|
||||
format?: DateTimeFormatOptions | string;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -22,13 +22,24 @@ export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
||||
const { locale } = useLocale();
|
||||
|
||||
const formatDateTime = useCallback(
|
||||
(date: DateTime) => {
|
||||
if (typeof format === 'string') {
|
||||
return date.toFormat(format);
|
||||
}
|
||||
|
||||
return date.toLocaleString(format);
|
||||
},
|
||||
[format],
|
||||
);
|
||||
|
||||
const [localeDate, setLocaleDate] = useState(() =>
|
||||
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
|
||||
formatDateTime(DateTime.fromJSDate(new Date(date)).setLocale(locale)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
|
||||
}, [date, format]);
|
||||
setLocaleDate(formatDateTime(DateTime.fromJSDate(new Date(date))));
|
||||
}, [date, format, formatDateTime]);
|
||||
|
||||
return (
|
||||
<span className={className} {...props}>
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { renderSVG } from 'uqr';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -54,14 +52,16 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EnableAuthenticatorAppDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.setup.useMutation();
|
||||
|
||||
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.enable.useMutation();
|
||||
const {
|
||||
mutateAsync: enableTwoFactorAuthentication,
|
||||
data: enableTwoFactorAuthenticationData,
|
||||
isLoading: isEnableTwoFactorAuthenticationDataLoading,
|
||||
} = trpc.twoFactorAuthentication.enable.useMutation();
|
||||
|
||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
@ -115,6 +115,19 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
|
||||
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
downloadFile({
|
||||
filename: 'documenso-2FA-recovery-codes.txt',
|
||||
data: blob,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
||||
token,
|
||||
}: TEnableTwoFactorAuthenticationForm) => {
|
||||
@ -136,14 +149,6 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onCompleteClick = () => {
|
||||
flushSync(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
@ -270,9 +275,16 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
||||
<Button type="button" onClick={() => onCompleteClick()}>
|
||||
Complete
|
||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={downloadRecoveryCodes}
|
||||
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
|
||||
loading={isEnableTwoFactorAuthenticationDataLoading}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -42,8 +43,11 @@ export type ViewRecoveryCodesDialogProps = {
|
||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
|
||||
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||
const {
|
||||
mutateAsync: viewRecoveryCodes,
|
||||
data: viewRecoveryCodesData,
|
||||
isLoading: isViewRecoveryCodesDataLoading,
|
||||
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||
|
||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||
defaultValues: {
|
||||
@ -62,6 +66,19 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
||||
return 'view';
|
||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
|
||||
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
downloadFile({
|
||||
filename: 'documenso-2FA-recovery-codes.txt',
|
||||
data: blob,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
||||
try {
|
||||
await viewRecoveryCodes({ password });
|
||||
@ -139,8 +156,17 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse items-center justify-between">
|
||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!viewRecoveryCodesData?.recoveryCodes}
|
||||
loading={isViewRecoveryCodesDataLoading}
|
||||
onClick={downloadRecoveryCodes}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -158,6 +158,7 @@
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"remeda": "^1.27.1",
|
||||
"sharp": "0.33.1",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
@ -15749,6 +15750,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-colorful": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
|
||||
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-confetti": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz",
|
||||
@ -19817,6 +19827,7 @@
|
||||
"luxon": "^3.4.2",
|
||||
"next": "14.0.3",
|
||||
"pdfjs-dist": "3.6.172",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-pdf": "7.3.3",
|
||||
|
||||
@ -15,7 +15,7 @@ test('[PR-713]: should see sent documents', async ({ page }) => {
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').fill('sent');
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill('sent');
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||
});
|
||||
|
||||
@ -32,7 +32,7 @@ test('[PR-713]: should see received documents', async ({ page }) => {
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').fill('received');
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill('received');
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
|
||||
});
|
||||
|
||||
@ -49,6 +49,6 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||
});
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||
import {
|
||||
extractUserVerificationToken,
|
||||
seedUser,
|
||||
unseedUser,
|
||||
unseedUserByEmail,
|
||||
} from '@documenso/prisma/seed/users';
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
/*
|
||||
Using them sequentially so the 2nd test
|
||||
uses the details from the 1st (registration) test
|
||||
*/
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const username = 'Test User';
|
||||
const email = 'test-user@auth-flow.documenso.com';
|
||||
const password = 'Password123#';
|
||||
|
||||
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||
const username = 'Test User';
|
||||
const email = `test-user-${Date.now()}@auth-flow.documenso.com`;
|
||||
const password = 'Password123#';
|
||||
|
||||
await page.goto('/signup');
|
||||
await page.getByLabel('Name').fill(username);
|
||||
await page.getByLabel('Email').fill(email);
|
||||
@ -31,25 +30,33 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
|
||||
|
||||
await page.waitForURL('/unverified-account');
|
||||
|
||||
const { token } = await extractUserVerificationToken(email);
|
||||
|
||||
await page.goto(`/verify-email/${token}`);
|
||||
|
||||
await expect(page.getByRole('heading')).toContainText('Email Confirmed!');
|
||||
|
||||
await page.getByRole('link', { name: 'Go back home' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page).toHaveURL('/documents');
|
||||
await unseedUserByEmail(email);
|
||||
});
|
||||
|
||||
test('user can login with user and password', async ({ page }: { page: Page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await page.goto('/signin');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
await expect(page).toHaveURL('/documents');
|
||||
});
|
||||
|
||||
test.afterAll('Teardown', async () => {
|
||||
try {
|
||||
await deleteUser({ email });
|
||||
} catch (e) {
|
||||
throw new Error(`Error deleting user: ${e}`);
|
||||
}
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
19
packages/lib/client-only/download-file.ts
Normal file
19
packages/lib/client-only/download-file.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export type DownloadFileOptions = {
|
||||
filename: string;
|
||||
data: Blob;
|
||||
};
|
||||
|
||||
export const downloadFile = ({ filename, data }: DownloadFileOptions) => {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('downloadFile can only be called in browser environments');
|
||||
}
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
link.href = window.URL.createObjectURL(data);
|
||||
link.download = filename;
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
import type { DocumentData } from '@documenso/prisma/client';
|
||||
|
||||
import { getFile } from '../universal/upload/get-file';
|
||||
import { downloadFile } from './download-file';
|
||||
|
||||
type DownloadPDFProps = {
|
||||
documentData: DocumentData;
|
||||
@ -14,16 +15,12 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
const [baseTitle] = fileName?.includes('.pdf')
|
||||
? fileName.split('.pdf')
|
||||
: [fileName ?? 'document'];
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${baseTitle}_signed.pdf`;
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
downloadFile({
|
||||
filename: baseTitle,
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
19
packages/lib/constants/document-audit-logs.ts
Normal file
19
packages/lib/constants/document-audit-logs.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { DOCUMENT_EMAIL_TYPE } from '../types/document-audit-logs';
|
||||
|
||||
export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
|
||||
[DOCUMENT_EMAIL_TYPE.SIGNING_REQUEST]: {
|
||||
description: 'Signing request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.VIEW_REQUEST]: {
|
||||
description: 'Viewing request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
|
||||
description: 'Approval request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.CC]: {
|
||||
description: 'CC',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
|
||||
description: 'Document completed',
|
||||
},
|
||||
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;
|
||||
@ -22,6 +22,8 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||
app_teams: true,
|
||||
app_document_page_view_history_sheet: false,
|
||||
marketing_header_single_player_mode: false,
|
||||
} as const;
|
||||
|
||||
|
||||
@ -1,29 +1,31 @@
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
export const RECIPIENT_ROLES_DESCRIPTION: {
|
||||
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
|
||||
} = {
|
||||
export const RECIPIENT_ROLES_DESCRIPTION = {
|
||||
[RecipientRole.APPROVER]: {
|
||||
actionVerb: 'Approve',
|
||||
actioned: 'Approved',
|
||||
progressiveVerb: 'Approving',
|
||||
roleName: 'Approver',
|
||||
},
|
||||
[RecipientRole.CC]: {
|
||||
actionVerb: 'CC',
|
||||
actioned: `CC'd`,
|
||||
progressiveVerb: 'CC',
|
||||
roleName: 'CC',
|
||||
roleName: 'Cc',
|
||||
},
|
||||
[RecipientRole.SIGNER]: {
|
||||
actionVerb: 'Sign',
|
||||
actioned: 'Signed',
|
||||
progressiveVerb: 'Signing',
|
||||
roleName: 'Signer',
|
||||
},
|
||||
[RecipientRole.VIEWER]: {
|
||||
actionVerb: 'View',
|
||||
actioned: 'Viewed',
|
||||
progressiveVerb: 'Viewing',
|
||||
roleName: 'Viewer',
|
||||
},
|
||||
};
|
||||
} satisfies Record<keyof typeof RecipientRole, unknown>;
|
||||
|
||||
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Role } from '@documenso/prisma/client';
|
||||
import type { Role } from '@documenso/prisma/client';
|
||||
|
||||
export type UpdateUserOptions = {
|
||||
id: number;
|
||||
|
||||
@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return upsertedDocumentMeta;
|
||||
});
|
||||
|
||||
@ -10,27 +10,72 @@ import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type DeleteDocumentOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
status: DocumentStatus;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
|
||||
export const deleteDocument = async ({
|
||||
id,
|
||||
userId,
|
||||
status,
|
||||
requestMetadata,
|
||||
}: DeleteDocumentOptions) => {
|
||||
await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// if the document is a draft, hard-delete
|
||||
if (status === DocumentStatus.DRAFT) {
|
||||
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Currently redundant since deleting a document will delete the audit logs.
|
||||
// However may be useful if we disassociate audit lgos and documents if required.
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
|
||||
});
|
||||
}
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (status === DocumentStatus.PENDING) {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
@ -78,12 +123,26 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio
|
||||
}
|
||||
|
||||
// If the document is not a draft, only soft-delete.
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date().toISOString(),
|
||||
},
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'SOFT',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
115
packages/lib/server-only/document/find-document-audit-logs.ts
Normal file
115
packages/lib/server-only/document/find-document-audit-logs.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentAuditLog } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export interface FindDocumentAuditLogsOptions {
|
||||
userId: number;
|
||||
documentId: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof DocumentAuditLog;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
cursor?: string;
|
||||
filterForRecentActivity?: boolean;
|
||||
}
|
||||
|
||||
export const findDocumentAuditLogs = async ({
|
||||
userId,
|
||||
documentId,
|
||||
page = 1,
|
||||
perPage = 30,
|
||||
orderBy,
|
||||
cursor,
|
||||
filterForRecentActivity,
|
||||
}: FindDocumentAuditLogsOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const whereClause: Prisma.DocumentAuditLogWhereInput = {
|
||||
documentId,
|
||||
};
|
||||
|
||||
// Filter events down to what we consider recent activity.
|
||||
if (filterForRecentActivity) {
|
||||
whereClause.OR = [
|
||||
{
|
||||
type: {
|
||||
in: [
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
data: {
|
||||
path: ['isResending'],
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.documentAuditLog.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage + 1,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
}),
|
||||
prisma.documentAuditLog.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
let nextCursor: string | undefined = undefined;
|
||||
|
||||
const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog));
|
||||
|
||||
if (parsedData.length > perPage) {
|
||||
const nextItem = parsedData.pop();
|
||||
nextCursor = nextItem!.id;
|
||||
}
|
||||
|
||||
return {
|
||||
data: parsedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
nextCursor,
|
||||
} satisfies FindResultSet<typeof parsedData> & { nextCursor?: string };
|
||||
};
|
||||
@ -21,6 +21,19 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -16,9 +16,8 @@ import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
documentId: number;
|
||||
@ -111,40 +110,43 @@ export const resendDocument = async ({
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@ -49,44 +49,47 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
|
||||
});
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
content: Buffer.from(buffer),
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
],
|
||||
});
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
content: Buffer.from(buffer),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user: null,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: 'DOCUMENT_COMPLETED',
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user: null,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: 'DOCUMENT_COMPLETED',
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@ -108,59 +108,76 @@ export const sendDocument = async ({
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: false,
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedDocument = await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
const updatedDocument = await prisma.$transaction(async (tx) => {
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
documentId: document.id,
|
||||
requestMetadata,
|
||||
user,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
|
||||
@ -24,34 +24,38 @@ export const updateTitle = async ({
|
||||
},
|
||||
});
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (document.title === title) {
|
||||
return document;
|
||||
}
|
||||
if (document.title === title) {
|
||||
return document;
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Instead of doing everything in a transaction we can use our knowledge
|
||||
// of the current document title to ensure we aren't performing a conflicting
|
||||
// update.
|
||||
const updatedDocument = await tx.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
title: document.title,
|
||||
},
|
||||
data: {
|
||||
title,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { nanoid } from '../../universal/id';
|
||||
|
||||
@ -9,6 +10,7 @@ export type SetRecipientsForTemplateOptions = {
|
||||
id?: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
}[];
|
||||
};
|
||||
|
||||
@ -84,11 +86,13 @@ export const setRecipientsForTemplate = async ({
|
||||
update: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
templateId,
|
||||
},
|
||||
create: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
token: nanoid(),
|
||||
templateId,
|
||||
},
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ZSiteSettingsSchema } from './schema';
|
||||
|
||||
export const getSiteSettings = async () => {
|
||||
const settings = await prisma.siteSettings.findMany();
|
||||
|
||||
return ZSiteSettingsSchema.parse(settings);
|
||||
};
|
||||
12
packages/lib/server-only/site-settings/schema.ts
Normal file
12
packages/lib/server-only/site-settings/schema.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSiteSettingsBannerSchema } from './schemas/banner';
|
||||
|
||||
// TODO: Use `z.union([...])` once we have more than one setting
|
||||
export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
|
||||
|
||||
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
|
||||
|
||||
export const ZSiteSettingsSchema = z.array(ZSiteSettingSchema);
|
||||
|
||||
export type TSiteSettingsSchema = z.infer<typeof ZSiteSettingsSchema>;
|
||||
9
packages/lib/server-only/site-settings/schemas/_base.ts
Normal file
9
packages/lib/server-only/site-settings/schemas/_base.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZSiteSettingsBaseSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
data: z.never(),
|
||||
});
|
||||
|
||||
export type TSiteSettingsBaseSchema = z.infer<typeof ZSiteSettingsBaseSchema>;
|
||||
23
packages/lib/server-only/site-settings/schemas/banner.ts
Normal file
23
packages/lib/server-only/site-settings/schemas/banner.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSiteSettingsBaseSchema } from './_base';
|
||||
|
||||
export const SITE_SETTINGS_BANNER_ID = 'site.banner';
|
||||
|
||||
export const ZSiteSettingsBannerSchema = ZSiteSettingsBaseSchema.extend({
|
||||
id: z.literal(SITE_SETTINGS_BANNER_ID),
|
||||
data: z
|
||||
.object({
|
||||
content: z.string(),
|
||||
bgColor: z.string(),
|
||||
textColor: z.string(),
|
||||
})
|
||||
.optional()
|
||||
.default({
|
||||
content: '',
|
||||
bgColor: '#000000',
|
||||
textColor: '#FFFFFF',
|
||||
}),
|
||||
});
|
||||
|
||||
export type TSiteSettingsBannerSchema = z.infer<typeof ZSiteSettingsBannerSchema>;
|
||||
@ -0,0 +1,33 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TSiteSettingSchema } from './schema';
|
||||
|
||||
export type UpsertSiteSettingOptions = TSiteSettingSchema & {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const upsertSiteSetting = async ({
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
userId,
|
||||
}: UpsertSiteSettingOptions) => {
|
||||
return await prisma.siteSettings.upsert({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
create: {
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
lastModifiedByUserId: userId,
|
||||
lastModifiedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
enabled,
|
||||
data,
|
||||
lastModifiedByUserId: userId,
|
||||
lastModifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -9,55 +9,58 @@ export type AcceptTeamInvitationOptions = {
|
||||
};
|
||||
|
||||
export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
|
||||
where: {
|
||||
teamId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
|
||||
where: {
|
||||
teamId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { team } = teamMemberInvite;
|
||||
const { team } = teamMemberInvite;
|
||||
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
userId: user.id,
|
||||
role: teamMemberInvite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
userId: user.id,
|
||||
role: teamMemberInvite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -28,56 +28,59 @@ export const createTeamEmailVerification = async ({
|
||||
data,
|
||||
}: CreateTeamEmailVerificationOptions) => {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.teamEmail || team.emailVerification) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Team already has an email or existing email verification.',
|
||||
);
|
||||
}
|
||||
if (team.teamEmail || team.emailVerification) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Team already has an email or existing email verification.',
|
||||
);
|
||||
}
|
||||
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
},
|
||||
});
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
}
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await tx.teamEmailVerification.create({
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
await tx.teamEmailVerification.create({
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
|
||||
});
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
@ -27,76 +27,81 @@ export const deleteTeamMembers = async ({
|
||||
teamId,
|
||||
teamMemberIds,
|
||||
}: DeleteTeamMembersOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Find the team and validate that the user is allowed to remove members.
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Find the team and validate that the user is allowed to remove members.
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
role: true,
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
const currentTeamMember = team.members.find((member) => member.userId === userId);
|
||||
const teamMembersToRemove = team.members.filter((member) =>
|
||||
teamMemberIds.includes(member.id),
|
||||
);
|
||||
|
||||
if (!currentTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
|
||||
}
|
||||
|
||||
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
|
||||
}
|
||||
|
||||
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
|
||||
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
|
||||
);
|
||||
|
||||
if (isMemberToRemoveHigherRole) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
|
||||
}
|
||||
|
||||
// Remove the team members.
|
||||
await tx.teamMember.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamMemberIds,
|
||||
},
|
||||
teamId,
|
||||
userId: {
|
||||
not: team.ownerUserId,
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
const currentTeamMember = team.members.find((member) => member.userId === userId);
|
||||
const teamMembersToRemove = team.members.filter((member) => teamMemberIds.includes(member.id));
|
||||
|
||||
if (!currentTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
|
||||
}
|
||||
|
||||
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
|
||||
}
|
||||
|
||||
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
|
||||
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
|
||||
);
|
||||
|
||||
if (isMemberToRemoveHigherRole) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
|
||||
}
|
||||
|
||||
// Remove the team members.
|
||||
await tx.teamMember.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamMemberIds,
|
||||
},
|
||||
teamId,
|
||||
userId: {
|
||||
not: team.ownerUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -9,34 +9,37 @@ export type DeleteTeamOptions = {
|
||||
};
|
||||
|
||||
export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.subscription) {
|
||||
await stripe.subscriptions
|
||||
.cancel(team.subscription.planId, {
|
||||
prorate: false,
|
||||
invoice_now: true,
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw AppError.parseError(err);
|
||||
});
|
||||
}
|
||||
if (team.subscription) {
|
||||
await stripe.subscriptions
|
||||
.cancel(team.subscription.planId, {
|
||||
prorate: false,
|
||||
invoice_now: true,
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw AppError.parseError(err);
|
||||
});
|
||||
}
|
||||
|
||||
await tx.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
await tx.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -15,45 +15,48 @@ export type LeaveTeamOptions = {
|
||||
};
|
||||
|
||||
export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: {
|
||||
not: userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMember.delete({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
team: {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: {
|
||||
not: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
await tx.teamMember.delete({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
team: {
|
||||
ownerUserId: {
|
||||
not: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -44,63 +44,66 @@ export const requestTeamOwnershipTransfer = async ({
|
||||
// Todo: Clear payment methods disabled for now.
|
||||
const clearPaymentMethods = false;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
members: {
|
||||
some: {
|
||||
userId: newOwnerUserId,
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
members: {
|
||||
some: {
|
||||
userId: newOwnerUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const newOwnerUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: newOwnerUserId,
|
||||
},
|
||||
});
|
||||
const newOwnerUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: newOwnerUserId,
|
||||
},
|
||||
});
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ minute: 10 });
|
||||
const { token, expiresAt } = createTokenVerification({ minute: 10 });
|
||||
|
||||
const teamVerificationPayload = {
|
||||
teamId,
|
||||
token,
|
||||
expiresAt,
|
||||
userId: newOwnerUserId,
|
||||
name: newOwnerUser.name ?? '',
|
||||
email: newOwnerUser.email,
|
||||
clearPaymentMethods,
|
||||
};
|
||||
|
||||
await tx.teamTransferVerification.upsert({
|
||||
where: {
|
||||
const teamVerificationPayload = {
|
||||
teamId,
|
||||
},
|
||||
create: teamVerificationPayload,
|
||||
update: teamVerificationPayload,
|
||||
});
|
||||
token,
|
||||
expiresAt,
|
||||
userId: newOwnerUserId,
|
||||
name: newOwnerUser.name ?? '',
|
||||
email: newOwnerUser.email,
|
||||
clearPaymentMethods,
|
||||
};
|
||||
|
||||
const template = createElement(TeamTransferRequestTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
senderName: userName,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
token,
|
||||
});
|
||||
await tx.teamTransferVerification.upsert({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
create: teamVerificationPayload,
|
||||
update: teamVerificationPayload,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: newOwnerUser.email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
});
|
||||
const template = createElement(TeamTransferRequestTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
senderName: userName,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
token,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: newOwnerUser.email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -17,49 +17,52 @@ export const resendTeamEmailVerification = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: ResendTeamMemberInvitationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
include: {
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a member of the team.');
|
||||
}
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a member of the team.');
|
||||
}
|
||||
|
||||
const { emailVerification } = team;
|
||||
const { emailVerification } = team;
|
||||
|
||||
if (!emailVerification) {
|
||||
throw new AppError(
|
||||
'VerificationNotFound',
|
||||
'No team email verification exists for this team.',
|
||||
);
|
||||
}
|
||||
if (!emailVerification) {
|
||||
throw new AppError(
|
||||
'VerificationNotFound',
|
||||
'No team email verification exists for this team.',
|
||||
);
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await tx.teamEmailVerification.update({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
await tx.teamEmailVerification.update({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
|
||||
});
|
||||
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -35,42 +35,45 @@ export const resendTeamMemberInvitation = async ({
|
||||
teamId,
|
||||
invitationId,
|
||||
}: ResendTeamMemberInvitationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
|
||||
}
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
|
||||
}
|
||||
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
|
||||
where: {
|
||||
id: invitationId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
|
||||
where: {
|
||||
id: invitationId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamMemberInvite) {
|
||||
throw new AppError('InviteNotFound', 'No invite exists for this user.');
|
||||
}
|
||||
if (!teamMemberInvite) {
|
||||
throw new AppError('InviteNotFound', 'No invite exists for this user.');
|
||||
}
|
||||
|
||||
await sendTeamMemberInviteEmail({
|
||||
email: teamMemberInvite.email,
|
||||
token: teamMemberInvite.token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
senderName: userName,
|
||||
});
|
||||
});
|
||||
await sendTeamMemberInviteEmail({
|
||||
email: teamMemberInvite.email,
|
||||
token: teamMemberInvite.token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
senderName: userName,
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -11,78 +11,81 @@ export type TransferTeamOwnershipOptions = {
|
||||
};
|
||||
|
||||
export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { team, userId: newOwnerUserId } = teamTransferVerification;
|
||||
|
||||
await tx.teamTransferVerification.delete({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const newOwnerUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: newOwnerUserId,
|
||||
teamMembers: {
|
||||
some: {
|
||||
teamId: team.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
teamSubscription = await transferTeamSubscription({
|
||||
user: newOwnerUser,
|
||||
team,
|
||||
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
|
||||
});
|
||||
}
|
||||
|
||||
if (teamSubscription) {
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
|
||||
);
|
||||
}
|
||||
const { team, userId: newOwnerUserId } = teamTransferVerification;
|
||||
|
||||
await tx.team.update({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
data: {
|
||||
ownerUserId: newOwnerUserId,
|
||||
members: {
|
||||
update: {
|
||||
where: {
|
||||
userId_teamId: {
|
||||
teamId: team.id,
|
||||
userId: newOwnerUserId,
|
||||
await tx.teamTransferVerification.delete({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const newOwnerUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: newOwnerUserId,
|
||||
teamMembers: {
|
||||
some: {
|
||||
teamId: team.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
teamSubscription = await transferTeamSubscription({
|
||||
user: newOwnerUser,
|
||||
team,
|
||||
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
|
||||
});
|
||||
}
|
||||
|
||||
if (teamSubscription) {
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
|
||||
);
|
||||
}
|
||||
|
||||
await tx.team.update({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
data: {
|
||||
ownerUserId: newOwnerUserId,
|
||||
members: {
|
||||
update: {
|
||||
where: {
|
||||
userId_teamId: {
|
||||
teamId: team.id,
|
||||
userId: newOwnerUserId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -57,6 +57,7 @@ export const createDocumentFromTemplate = async ({
|
||||
create: template.Recipient.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
token: nanoid(),
|
||||
})),
|
||||
},
|
||||
|
||||
@ -53,47 +53,50 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
await Promise.allSettled(
|
||||
acceptedTeamInvites.map(async (invite) =>
|
||||
prisma
|
||||
.$transaction(async (tx) => {
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: invite.teamId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: invite.teamId,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
.$transaction(
|
||||
async (tx) => {
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: invite.teamId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.subscription) {
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: team.members.length,
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: invite.teamId,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.subscription) {
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: team.members.length,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.catch(async () => {
|
||||
await prisma.teamMemberInvite.update({
|
||||
where: {
|
||||
|
||||
@ -21,15 +21,24 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'RECIPIENT_UPDATED',
|
||||
|
||||
// Document events.
|
||||
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
|
||||
'DOCUMENT_CREATED', // When the document is created.
|
||||
'DOCUMENT_DELETED', // When the document is soft deleted.
|
||||
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
|
||||
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
|
||||
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
|
||||
'DOCUMENT_OPENED', // When the document is opened by a recipient.
|
||||
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
|
||||
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
|
||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||
]);
|
||||
|
||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
'SIGNING_REQUEST',
|
||||
'VIEW_REQUEST',
|
||||
'APPROVE_REQUEST',
|
||||
'CC',
|
||||
'DOCUMENT_COMPLETED',
|
||||
'DOCUMENT_CREATED',
|
||||
'DOCUMENT_DELETED',
|
||||
'DOCUMENT_FIELD_INSERTED',
|
||||
'DOCUMENT_FIELD_UNINSERTED',
|
||||
'DOCUMENT_META_UPDATED',
|
||||
'DOCUMENT_OPENED',
|
||||
'DOCUMENT_TITLE_UPDATED',
|
||||
'DOCUMENT_RECIPIENT_COMPLETED',
|
||||
]);
|
||||
|
||||
export const ZDocumentMetaDiffTypeSchema = z.enum([
|
||||
@ -40,10 +49,12 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
|
||||
'SUBJECT',
|
||||
'TIMEZONE',
|
||||
]);
|
||||
|
||||
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
|
||||
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
|
||||
|
||||
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
|
||||
export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum;
|
||||
export const DOCUMENT_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum;
|
||||
export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum;
|
||||
export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum;
|
||||
@ -140,13 +151,7 @@ const ZBaseRecipientDataSchema = z.object({
|
||||
export const ZDocumentAuditLogEventEmailSentSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
emailType: z.enum([
|
||||
'SIGNING_REQUEST',
|
||||
'VIEW_REQUEST',
|
||||
'APPROVE_REQUEST',
|
||||
'CC',
|
||||
'DOCUMENT_COMPLETED',
|
||||
]),
|
||||
emailType: ZDocumentAuditLogEmailTypeSchema,
|
||||
isResending: z.boolean(),
|
||||
}),
|
||||
});
|
||||
@ -171,6 +176,16 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document deleted.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED),
|
||||
data: z.object({
|
||||
type: z.enum(['SOFT', 'HARD']),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document field inserted.
|
||||
*/
|
||||
@ -247,6 +262,14 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
|
||||
data: ZBaseRecipientDataSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document sent.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentSentSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT),
|
||||
data: z.object({}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document title updated.
|
||||
*/
|
||||
@ -314,6 +337,11 @@ export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
documentId: z.number(),
|
||||
name: z.string().optional().nullable(),
|
||||
email: z.string().optional().nullable(),
|
||||
userId: z.number().optional().nullable(),
|
||||
userAgent: z.string().optional().nullable(),
|
||||
ipAddress: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
@ -321,11 +349,13 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventEmailSentSchema,
|
||||
ZDocumentAuditLogEventDocumentCompletedSchema,
|
||||
ZDocumentAuditLogEventDocumentCreatedSchema,
|
||||
ZDocumentAuditLogEventDocumentDeletedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentOpenedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
||||
ZDocumentAuditLogEventDocumentSentSchema,
|
||||
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||
ZDocumentAuditLogEventFieldCreatedSchema,
|
||||
ZDocumentAuditLogEventFieldRemovedSchema,
|
||||
@ -348,3 +378,8 @@ export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer<
|
||||
export type TDocumentAuditLogRecipientDiffSchema = z.infer<
|
||||
typeof ZDocumentAuditLogRecipientDiffSchema
|
||||
>;
|
||||
|
||||
export type DocumentAuditLogByType<T = TDocumentAuditLog['type']> = Extract<
|
||||
TDocumentAuditLog,
|
||||
{ type: T }
|
||||
>;
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type {
|
||||
DocumentAuditLog,
|
||||
DocumentMeta,
|
||||
Field,
|
||||
Recipient,
|
||||
RecipientRole,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '../constants/recipient-roles';
|
||||
import type {
|
||||
TDocumentAuditLog,
|
||||
TDocumentAuditLogDocumentMetaDiffSchema,
|
||||
@ -7,6 +16,7 @@ import type {
|
||||
TDocumentAuditLogRecipientDiffSchema,
|
||||
} from '../types/document-audit-logs';
|
||||
import {
|
||||
DOCUMENT_AUDIT_LOG_TYPE,
|
||||
DOCUMENT_META_DIFF_TYPE,
|
||||
FIELD_DIFF_TYPE,
|
||||
RECIPIENT_DIFF_TYPE,
|
||||
@ -58,6 +68,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
|
||||
|
||||
// Handle any required migrations here.
|
||||
if (!data.success) {
|
||||
console.error(data.error);
|
||||
throw new Error('Migration required');
|
||||
}
|
||||
|
||||
@ -203,3 +214,114 @@ export const diffDocumentMetaChanges = (
|
||||
|
||||
return diffs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the audit log into a description of the action.
|
||||
*
|
||||
* Provide a userId to prefix the action with the user, example 'X did Y'.
|
||||
*/
|
||||
export const formatDocumentAuditLogActionString = (
|
||||
auditLog: TDocumentAuditLog,
|
||||
userId?: number,
|
||||
) => {
|
||||
const { prefix, description } = formatDocumentAuditLogAction(auditLog, userId);
|
||||
|
||||
return prefix ? `${prefix} ${description}` : description;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the audit log into a description of the action.
|
||||
*
|
||||
* Provide a userId to prefix the action with the user, example 'X did Y'.
|
||||
*/
|
||||
export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId?: number) => {
|
||||
let prefix = userId === auditLog.userId ? 'You' : auditLog.name || auditLog.email || '';
|
||||
|
||||
const description = match(auditLog)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
|
||||
anonymous: 'A field was added',
|
||||
identified: 'added a field',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
|
||||
anonymous: 'A field was removed',
|
||||
identified: 'removed a field',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
|
||||
anonymous: 'A field was updated',
|
||||
identified: 'updated a field',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
|
||||
anonymous: 'A recipient was added',
|
||||
identified: 'added a recipient',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
|
||||
anonymous: 'A recipient was removed',
|
||||
identified: 'removed a recipient',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
|
||||
anonymous: 'A recipient was updated',
|
||||
identified: 'updated a recipient',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
|
||||
anonymous: 'Document created',
|
||||
identified: 'created the document',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
|
||||
anonymous: 'Document deleted',
|
||||
identified: 'deleted the document',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
|
||||
anonymous: 'Field signed',
|
||||
identified: 'signed a field',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
|
||||
anonymous: 'Field unsigned',
|
||||
identified: 'unsigned a field',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
|
||||
anonymous: 'Document updated',
|
||||
identified: 'updated the document',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
|
||||
anonymous: 'Document opened',
|
||||
identified: 'opened the document',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
|
||||
anonymous: 'Document title updated',
|
||||
identified: 'updated the document title',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
|
||||
anonymous: 'Document sent',
|
||||
identified: 'sent the document',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const action = RECIPIENT_ROLES_DESCRIPTION[data.recipientRole as RecipientRole]?.actioned;
|
||||
|
||||
const value = action ? `${action.toLowerCase()} the document` : 'completed their task';
|
||||
|
||||
return {
|
||||
anonymous: `Recipient ${value}`,
|
||||
identified: value,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
|
||||
anonymous: `Email ${data.isResending ? 'resent' : 'sent'}`,
|
||||
identified: `${data.isResending ? 'resent' : 'sent'} an email`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => {
|
||||
// Clear the prefix since this should be considered an 'anonymous' event.
|
||||
prefix = '';
|
||||
|
||||
return {
|
||||
anonymous: 'Document completed',
|
||||
identified: 'Document completed',
|
||||
};
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
return {
|
||||
prefix,
|
||||
description: prefix ? description.identified : description.anonymous,
|
||||
};
|
||||
};
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Banner" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"customHTML" TEXT NOT NULL,
|
||||
"userId" INTEGER,
|
||||
|
||||
CONSTRAINT "Banner_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Banner" ADD CONSTRAINT "Banner_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Banner" ADD COLUMN "show" BOOLEAN NOT NULL DEFAULT false;
|
||||
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `customHTML` on the `Banner` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Banner" DROP COLUMN "customHTML";
|
||||
@ -0,0 +1,25 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Banner` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Banner" DROP CONSTRAINT "Banner_userId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Banner";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SiteSettings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"data" JSONB NOT NULL,
|
||||
"lastModifiedByUserId" INTEGER,
|
||||
"lastModifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SiteSettings" ADD CONSTRAINT "SiteSettings_lastModifiedByUserId_fkey" FOREIGN KEY ("lastModifiedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,13 @@
|
||||
INSERT INTO "SiteSettings" ("id", "enabled", "data")
|
||||
VALUES (
|
||||
'site.banner',
|
||||
FALSE,
|
||||
jsonb_build_object(
|
||||
'content',
|
||||
'This is a test banner',
|
||||
'bgColor',
|
||||
'#000000',
|
||||
'textColor',
|
||||
'#ffffff'
|
||||
)
|
||||
);
|
||||
@ -47,6 +47,7 @@ model User {
|
||||
VerificationToken VerificationToken[]
|
||||
Template Template[]
|
||||
securityAuditLogs UserSecurityAuditLog[]
|
||||
siteSettings SiteSettings[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@ -210,14 +211,14 @@ model DocumentData {
|
||||
}
|
||||
|
||||
model DocumentMeta {
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
message String?
|
||||
timezone String? @default("Etc/UTC") @db.Text
|
||||
password String?
|
||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||
documentId Int @unique
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
message String?
|
||||
timezone String? @default("Etc/UTC") @db.Text
|
||||
password String?
|
||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||
documentId Int @unique
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
redirectUrl String?
|
||||
}
|
||||
|
||||
@ -450,3 +451,12 @@ model Template {
|
||||
|
||||
@@unique([templateDocumentDataId])
|
||||
}
|
||||
|
||||
model SiteSettings {
|
||||
id String @id
|
||||
enabled Boolean @default(false)
|
||||
data Json
|
||||
lastModifiedByUserId Int?
|
||||
lastModifiedAt DateTime @default(now())
|
||||
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id])
|
||||
}
|
||||
|
||||
@ -32,3 +32,22 @@ export const unseedUser = async (userId: number) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const unseedUserByEmail = async (email: string) => {
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const extractUserVerificationToken = async (email: string) => {
|
||||
return await prisma.verificationToken.findFirstOrThrow({
|
||||
where: {
|
||||
identifier: 'confirmation-email',
|
||||
user: {
|
||||
email,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
|
||||
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
|
||||
|
||||
import { adminProcedure, router } from '../trpc';
|
||||
import { ZUpdateProfileMutationByAdminSchema } from './schema';
|
||||
import { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema';
|
||||
|
||||
export const adminRouter = router({
|
||||
updateUser: adminProcedure
|
||||
@ -20,4 +21,24 @@ export const adminRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updateSiteSetting: adminProcedure
|
||||
.input(ZUpdateSiteSettingMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const { id, enabled, data } = input;
|
||||
|
||||
return await upsertSiteSetting({
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to update the site setting provided.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Role } from '@prisma/client';
|
||||
import z from 'zod';
|
||||
|
||||
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema';
|
||||
|
||||
export const ZUpdateProfileMutationByAdminSchema = z.object({
|
||||
id: z.number().min(1),
|
||||
name: z.string().nullish(),
|
||||
@ -11,3 +13,7 @@ export const ZUpdateProfileMutationByAdminSchema = z.object({
|
||||
export type TUpdateProfileMutationByAdminSchema = z.infer<
|
||||
typeof ZUpdateProfileMutationByAdminSchema
|
||||
>;
|
||||
|
||||
export const ZUpdateSiteSettingMutationSchema = ZSiteSettingSchema;
|
||||
|
||||
export type TUpdateSiteSettingMutationSchema = z.infer<typeof ZUpdateSiteSettingMutationSchema>;
|
||||
|
||||
@ -6,6 +6,7 @@ import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/ups
|
||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
|
||||
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
@ -21,6 +22,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateDocumentMutationSchema,
|
||||
ZDeleteDraftDocumentMutationSchema,
|
||||
ZFindDocumentAuditLogsQuerySchema,
|
||||
ZGetDocumentByIdQuerySchema,
|
||||
ZGetDocumentByTokenQuerySchema,
|
||||
ZResendDocumentMutationSchema,
|
||||
@ -111,7 +113,12 @@ export const documentRouter = router({
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
return await deleteDocument({ id, userId, status });
|
||||
return await deleteDocument({
|
||||
id,
|
||||
userId,
|
||||
status,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -122,6 +129,31 @@ export const documentRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
findDocumentAuditLogs: authenticatedProcedure
|
||||
.input(ZFindDocumentAuditLogsQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { page, perPage, documentId, cursor, filterForRecentActivity, orderBy } = input;
|
||||
|
||||
return await findDocumentAuditLogs({
|
||||
page,
|
||||
perPage,
|
||||
documentId,
|
||||
cursor,
|
||||
filterForRecentActivity,
|
||||
orderBy,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to find audit logs for this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
setTitleForDocument: authenticatedProcedure
|
||||
.input(ZSetTitleForDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
export const ZFindDocumentAuditLogsQuerySchema = ZBaseTableSearchParamsSchema.extend({
|
||||
documentId: z.number().min(1),
|
||||
cursor: z.string().optional(),
|
||||
filterForRecentActivity: z.boolean().optional(),
|
||||
orderBy: z
|
||||
.object({
|
||||
column: z.enum(['createdAt', 'type']),
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZGetDocumentByIdQuerySchema = z.object({
|
||||
id: z.number().min(1),
|
||||
teamId: z.number().min(1).optional(),
|
||||
|
||||
@ -53,6 +53,7 @@ export const recipientRouter = router({
|
||||
id: signer.nativeId,
|
||||
email: signer.email,
|
||||
name: signer.name,
|
||||
role: signer.role,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@ -34,6 +34,7 @@ export const ZAddTemplateSignersMutationSchema = z
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@ -3,10 +3,11 @@ import { z } from 'zod';
|
||||
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
// Consider refactoring to use ZBaseTableSearchParamsSchema.
|
||||
const GenericFindQuerySchema = z.object({
|
||||
term: z.string().optional(),
|
||||
page: z.number().optional(),
|
||||
perPage: z.number().optional(),
|
||||
page: z.number().min(1).optional(),
|
||||
perPage: z.number().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -64,6 +64,7 @@
|
||||
"luxon": "^3.4.2",
|
||||
"next": "14.0.3",
|
||||
"pdfjs-dist": "3.6.172",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-pdf": "7.3.3",
|
||||
|
||||
@ -6,16 +6,20 @@ import { cva } from 'class-variance-authority';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'inline-flex items-center rounded-md px-2 py-1.5 text-xs font-medium ring-1 ring-inset w-fit',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary hover:bg-primary/80 border-transparent text-primary-foreground',
|
||||
secondary:
|
||||
'bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground',
|
||||
neutral:
|
||||
'bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20',
|
||||
destructive:
|
||||
'bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground',
|
||||
outline: 'text-foreground',
|
||||
'bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20',
|
||||
warning:
|
||||
'bg-yellow-50 text-yellow-800 ring-yellow-600/20 dark:bg-yellow-400/10 dark:text-yellow-500 dark:ring-yellow-400/20',
|
||||
default:
|
||||
'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20',
|
||||
secondary:
|
||||
'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
82
packages/ui/primitives/color-picker.tsx
Normal file
82
packages/ui/primitives/color-picker.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { HexColorInput, HexColorPicker } from 'react-colorful';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
|
||||
export type ColorPickerProps = {
|
||||
disabled?: boolean;
|
||||
value: string;
|
||||
defaultValue?: string;
|
||||
onChange: (color: string) => void;
|
||||
} & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ColorPicker = ({
|
||||
className,
|
||||
disabled = false,
|
||||
value,
|
||||
defaultValue = '#000000',
|
||||
onChange,
|
||||
...props
|
||||
}: ColorPickerProps) => {
|
||||
const [color, setColor] = useState(value || defaultValue);
|
||||
const [inputColor, setInputColor] = useState(value || defaultValue);
|
||||
|
||||
const onColorChange = (newColor: string) => {
|
||||
setColor(newColor);
|
||||
setInputColor(newColor);
|
||||
onChange(newColor);
|
||||
};
|
||||
|
||||
const onInputChange = (newColor: string) => {
|
||||
setInputColor(newColor);
|
||||
};
|
||||
|
||||
const onInputBlur = () => {
|
||||
setColor(inputColor);
|
||||
onChange(inputColor);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="bg-background h-12 w-12 rounded-md border p-1 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<div className="h-full w-full rounded-sm" style={{ backgroundColor: color }} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-auto">
|
||||
<HexColorPicker
|
||||
className={cn(
|
||||
className,
|
||||
'w-full aria-disabled:pointer-events-none aria-disabled:opacity-50',
|
||||
)}
|
||||
color={color}
|
||||
onChange={onColorChange}
|
||||
aria-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<HexColorInput
|
||||
className="mt-4 h-10 rounded-md border bg-transparent px-3 py-2 text-sm disabled:pointer-events-none disabled:opacity-50"
|
||||
color={inputColor}
|
||||
onChange={onInputChange}
|
||||
onBlur={onInputBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onInputBlur();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@ -4,7 +4,7 @@ import React, { useId } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { BadgeCheck, Copy, Eye, PencilLine, Plus, Trash } from 'lucide-react';
|
||||
import { Plus, Trash } from 'lucide-react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
@ -17,6 +17,7 @@ import { Button } from '../button';
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
import { Input } from '../input';
|
||||
import { Label } from '../label';
|
||||
import { ROLE_ICONS } from '../recipient-role-icons';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
|
||||
import { useStep } from '../stepper';
|
||||
import { useToast } from '../use-toast';
|
||||
@ -32,13 +33,6 @@ import {
|
||||
import { ShowFieldItem } from './show-field-item';
|
||||
import type { DocumentFlowStep } from './types';
|
||||
|
||||
const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
|
||||
SIGNER: <PencilLine className="h-4 w-4" />,
|
||||
APPROVER: <BadgeCheck className="h-4 w-4" />,
|
||||
CC: <Copy className="h-4 w-4" />,
|
||||
VIEWER: <Eye className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
export type AddSignersFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
|
||||
@ -233,18 +233,20 @@ export const PDFViewer = ({
|
||||
{Array(numPages)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border my-8 overflow-hidden rounded border will-change-transform first:mt-0 last:mb-0"
|
||||
>
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
<div key={i} className="last:-mb-2">
|
||||
<div className="border-border overflow-hidden rounded border will-change-transform">
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground/80 my-2 text-center text-[11px]">
|
||||
Page {i + 1} of {numPages}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</PDFDocument>
|
||||
|
||||
10
packages/ui/primitives/recipient-role-icons.tsx
Normal file
10
packages/ui/primitives/recipient-role-icons.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { BadgeCheck, Copy, Eye, PencilLine } from 'lucide-react';
|
||||
|
||||
import type { RecipientRole } from '.prisma/client';
|
||||
|
||||
export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
|
||||
SIGNER: <PencilLine className="h-4 w-4" />,
|
||||
APPROVER: <BadgeCheck className="h-4 w-4" />,
|
||||
CC: <Copy className="h-4 w-4" />,
|
||||
VIEWER: <Eye className="h-4 w-4" />,
|
||||
};
|
||||
@ -143,14 +143,17 @@ const sheetVariants = cva(
|
||||
|
||||
export interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
VariantProps<typeof sheetVariants> {
|
||||
showOverlay?: boolean;
|
||||
sheetClass?: string;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ position, size, className, children, ...props }, ref) => (
|
||||
>(({ position, size, className, sheetClass, showOverlay = true, children, ...props }, ref) => (
|
||||
<SheetPortal position={position}>
|
||||
<SheetOverlay />
|
||||
{showOverlay && <SheetOverlay className={sheetClass} />}
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ position, size }), className)}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Caveat } from 'next/font/google';
|
||||
|
||||
@ -10,9 +10,10 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -291,6 +292,28 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
setSelectedSigner(recipients[0]);
|
||||
}, [recipients]);
|
||||
|
||||
const recipientsByRole = useMemo(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
CC: [],
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
APPROVER: [],
|
||||
};
|
||||
|
||||
recipients.forEach((recipient) => {
|
||||
recipientsByRole[recipient.role].push(recipient);
|
||||
});
|
||||
|
||||
return recipientsByRole;
|
||||
}, [recipients]);
|
||||
|
||||
const recipientsByRoleToDisplay = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
|
||||
([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER,
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerContent>
|
||||
@ -363,55 +386,49 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{recipients.map((recipient, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
className={cn({
|
||||
// 'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
})}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
{/* {recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
aria-hidden={recipient !== selectedSigner}
|
||||
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
|
||||
'opacity-0': recipient !== selectedSigner,
|
||||
'opacity-100': recipient === selectedSigner,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
This document has already been sent to this recipient. You can no
|
||||
longer edit this recipient.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)} */}
|
||||
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
||||
</div>
|
||||
|
||||
{recipient.name && (
|
||||
{recipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
>
|
||||
No recipients with this role
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipients.map((recipient) => (
|
||||
<CommandItem
|
||||
key={recipient.id}
|
||||
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1')}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={`${recipient.name} (${recipient.email})`}
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
'text-foreground/80': recipient === selectedSigner,
|
||||
})}
|
||||
>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && (
|
||||
<span className="truncate" title={recipient.email}>
|
||||
{recipient.email}
|
||||
{!recipient.name && (
|
||||
<span title={recipient.email}>{recipient.email}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@ -5,10 +5,10 @@ import React, { useId, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Plus, Trash } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@ -21,6 +21,8 @@ import {
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '../document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '../document-flow/types';
|
||||
import { ROLE_ICONS } from '../recipient-role-icons';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
|
||||
import { useStep } from '../stepper';
|
||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||
@ -59,12 +61,14 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
formId: initialId,
|
||||
name: `Recipient 1`,
|
||||
email: `recipient.1@documenso.com`,
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -86,6 +90,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
formId: nanoid(12),
|
||||
name: `Recipient ${placeholderRecipientCount}`,
|
||||
email: `recipient.${placeholderRecipientCount}@documenso.com`,
|
||||
role: RecipientRole.SIGNER,
|
||||
});
|
||||
|
||||
setPlaceholderRecipientCount((count) => count + 1);
|
||||
@ -95,12 +100,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
removeSigner(index);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
|
||||
onAddPlaceholderRecipient();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerContent>
|
||||
@ -113,10 +112,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
className="flex flex-wrap items-end gap-x-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor={`signer-${signer.id}-email`}>
|
||||
Email
|
||||
<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
||||
</Label>
|
||||
<Label htmlFor={`signer-${signer.id}-email`}>Email</Label>
|
||||
|
||||
<Input
|
||||
id={`signer-${signer.id}-email`}
|
||||
@ -139,6 +135,48 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[60px]">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`signers.${index}.role`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select value={value} onValueChange={(x) => onChange(x)}>
|
||||
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
|
||||
|
||||
<SelectContent className="" align="end">
|
||||
<SelectItem value={RecipientRole.SIGNER}>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
||||
Signer
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value={RecipientRole.CC}>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
||||
Receives copy
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value={RecipientRole.APPROVER}>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
||||
Approver
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value={RecipientRole.VIEWER}>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
||||
Viewer
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { RecipientRole } from '.prisma/client';
|
||||
|
||||
export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
||||
.object({
|
||||
signers: z.array(
|
||||
@ -8,6 +10,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user