mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Merge branch 'main' into feat/webhook-implementation
This commit is contained in:
@ -10,7 +10,13 @@
|
|||||||
"ghcr.io/devcontainers/features/node:1": {}
|
"ghcr.io/devcontainers/features/node:1": {}
|
||||||
},
|
},
|
||||||
"onCreateCommand": "./.devcontainer/on-create.sh",
|
"onCreateCommand": "./.devcontainer/on-create.sh",
|
||||||
"forwardPorts": [3000, 54320, 9000, 2500, 1100],
|
"forwardPorts": [
|
||||||
|
3000,
|
||||||
|
54320,
|
||||||
|
9000,
|
||||||
|
2500,
|
||||||
|
1100
|
||||||
|
],
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
@ -25,7 +31,7 @@
|
|||||||
"GitHub.copilot",
|
"GitHub.copilot",
|
||||||
"GitHub.vscode-pull-request-github",
|
"GitHub.vscode-pull-request-github",
|
||||||
"Prisma.prisma",
|
"Prisma.prisma",
|
||||||
"VisualStudioExptTeam.vscodeintellicode",
|
"VisualStudioExptTeam.vscodeintellicode"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,11 +5,10 @@ import { allDocuments } from 'contentlayer/generated';
|
|||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||||
|
|
||||||
export const generateStaticParams = () =>
|
export const dynamic = 'force-dynamic';
|
||||||
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
|
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
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) {
|
if (!document) {
|
||||||
return { title: 'Not Found' };
|
return { title: 'Not Found' };
|
||||||
|
|||||||
@ -9,9 +9,6 @@ import { useMDXComponent } from 'next-contentlayer/hooks';
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export const generateStaticParams = () =>
|
|
||||||
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
|
|
||||||
|
|
||||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||||
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { allBlogPosts } from 'contentlayer/generated';
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Blog',
|
title: 'Blog',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const blogPosts = allBlogPosts.sort((a, b) => {
|
const blogPosts = allBlogPosts.sort((a, b) => {
|
||||||
const dateA = new Date(a.date);
|
const dateA = new Date(a.date);
|
||||||
|
|||||||
@ -256,6 +256,7 @@ export const SinglePlayerClient = () => {
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onSignSubmit}
|
onSubmit={onSignSubmit}
|
||||||
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
|
||||||
|
requireCustomText={Boolean(fields.find((field) => field.type === 'TEXT'))}
|
||||||
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { Suspense } from 'react';
|
|||||||
|
|
||||||
import { Caveat, Inter } from 'next/font/google';
|
import { Caveat, Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
import { PublicEnvScript } from 'next-runtime-env';
|
||||||
|
|
||||||
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
|
||||||
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
|
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="32x32" href="/favicon-32x32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<PublicEnvScript />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
|
|||||||
@ -114,7 +114,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button className="mt-6 rounded-full text-base" asChild>
|
<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>
|
</Button>
|
||||||
|
|
||||||
<div className="mt-8 flex w-full flex-col divide-y">
|
<div className="mt-8 flex w-full flex-col divide-y">
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/api": "*",
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
"@documenso/ee": "*",
|
"@documenso/ee": "*",
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
@ -42,6 +43,7 @@
|
|||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
|
"remeda": "^1.27.1",
|
||||||
"sharp": "0.33.1",
|
"sharp": "0.33.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
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 { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -78,6 +78,20 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
|||||||
Subscriptions
|
Subscriptions
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
</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 Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
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 { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
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 { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { Team } 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 { 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 { 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 = {
|
export type DocumentPageViewProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -44,6 +56,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
|
const isDocumentHistoryEnabled = await getServerComponentFlag(
|
||||||
|
'app_document_page_view_history_sheet',
|
||||||
|
);
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
if (!document || !document.documentData) {
|
||||||
redirect(documentRootPath);
|
redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
@ -67,16 +83,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
const recipients = await getRecipientsForDocument({
|
||||||
getRecipientsForDocument({
|
documentId,
|
||||||
documentId,
|
teamId: team?.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}),
|
});
|
||||||
getFieldsForDocument({
|
|
||||||
documentId,
|
const documentWithRecipients = {
|
||||||
userId: user.id,
|
...document,
|
||||||
}),
|
Recipient: recipients,
|
||||||
]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
@ -85,47 +101,105 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<div className="flex flex-row justify-between">
|
||||||
{document.title}
|
<div>
|
||||||
</h1>
|
<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">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
<DocumentStatusComponent
|
||||||
|
inheritColor
|
||||||
|
status={document.status}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
{recipients.length > 0 && (
|
||||||
<div className="text-muted-foreground flex items-center">
|
<div className="text-muted-foreground flex items-center">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
</StackAvatarsWithTooltip>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||||
<EditDocumentForm
|
<Card
|
||||||
className="mt-8"
|
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||||
document={document}
|
gradient
|
||||||
user={user}
|
>
|
||||||
documentMeta={documentMeta}
|
<CardContent className="p-2">
|
||||||
recipients={recipients}
|
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||||
fields={fields}
|
</CardContent>
|
||||||
documentData={documentData}
|
</Card>
|
||||||
documentRootPath={documentRootPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
<div className="space-y-6">
|
||||||
<LazyPDFViewer
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
document={document}
|
<div className="flex flex-row items-center justify-between px-4">
|
||||||
key={documentData.id}
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
documentMeta={documentMeta}
|
Document {FRIENDLY_STATUS_MAP[document.status].label.toLowerCase()}
|
||||||
documentData={documentData}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
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 {
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
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 type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -24,6 +30,8 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
user: User;
|
||||||
@ -49,12 +57,10 @@ export const EditDocumentForm = ({
|
|||||||
documentRootPath,
|
documentRootPath,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// controlled stepper state
|
const router = useRouter();
|
||||||
const [step, setStep] = useState<EditDocumentStep>(
|
const searchParams = useSearchParams();
|
||||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers',
|
const team = useOptionalCurrentTeam();
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||||
@ -86,11 +92,30 @@ 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) => {
|
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// Custom invocation server action
|
// Custom invocation server action
|
||||||
await addTitle({
|
await addTitle({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
teamId: team?.id,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,6 +138,7 @@ export const EditDocumentForm = ({
|
|||||||
// Custom invocation server action
|
// Custom invocation server action
|
||||||
await addSigners({
|
await addSigners({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
teamId: team?.id,
|
||||||
signers: data.signers,
|
signers: data.signers,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -156,6 +182,7 @@ export const EditDocumentForm = ({
|
|||||||
try {
|
try {
|
||||||
await sendDocument({
|
await sendDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
teamId: team?.id,
|
||||||
meta: {
|
meta: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
|
|||||||
@ -0,0 +1,122 @@
|
|||||||
|
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,
|
||||||
|
teamId: team?.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,151 @@
|
|||||||
|
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,
|
||||||
|
teamId: team?.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>
|
<DialogContent className="sm:max-w-sm" hideClose>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle asChild>
|
||||||
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@ -94,7 +94,7 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps)
|
|||||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||||
() => (
|
() => (
|
||||||
<Button className="w-32" asChild>
|
<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 className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
<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 className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
@ -193,6 +193,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
documentTitle={row.title}
|
documentTitle={row.title}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
teamId={team?.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
|
|||||||
@ -5,16 +5,19 @@ import Link from 'next/link';
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
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 = {
|
export type DataTableTitleProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
team: Pick<Team, 'url'> | null;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
};
|
};
|
||||||
|
teamUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@ -25,14 +28,18 @@ export const DataTableTitle = ({ row }: DataTableTitleProps) => {
|
|||||||
|
|
||||||
const isOwner = row.User.id === session.user.id;
|
const isOwner = row.User.id === session.user.id;
|
||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
|
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
isRecipient,
|
isRecipient,
|
||||||
|
isCurrentTeamDocument,
|
||||||
})
|
})
|
||||||
.with({ isOwner: true }, () => (
|
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
||||||
<Link
|
<Link
|
||||||
href={`/documents/${row.id}`}
|
href={`${documentsPath}/${row.id}`}
|
||||||
title={row.title}
|
title={row.title}
|
||||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export const DocumentsDataTable = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sender',
|
id: 'sender',
|
||||||
|
|||||||
@ -16,12 +16,13 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type DeleteDraftDocumentDialogProps = {
|
type DeleteDocumentDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
status: DocumentStatus;
|
status: DocumentStatus;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteDocumentDialog = ({
|
export const DeleteDocumentDialog = ({
|
||||||
@ -30,7 +31,8 @@ export const DeleteDocumentDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
status,
|
status,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
}: DeleteDraftDocumentDialogProps) => {
|
teamId,
|
||||||
|
}: DeleteDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -61,7 +63,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteDocument({ id, status });
|
await deleteDocument({ id, teamId });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: (newId) => {
|
onSuccess: (newId) => {
|
||||||
router.push(`${documentsPath}/${newId}`);
|
router.push(`${documentsPath}/${newId}/edit`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: 'Document Duplicated',
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
|
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
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 { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
@ -37,6 +38,8 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
|
||||||
|
<Banner />
|
||||||
|
|
||||||
<Header user={user} teams={teams} />
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|||||||
@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DeleteAccountDialogProps = {
|
||||||
|
className?: string;
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
||||||
|
|
||||||
|
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
|
||||||
|
trpc.profile.deleteAccount.useMutation();
|
||||||
|
|
||||||
|
const onDeleteAccount = async () => {
|
||||||
|
try {
|
||||||
|
await deleteAccount();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Account deleted',
|
||||||
|
description: 'Your account has been deleted successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await signOut({ callbackUrl: '/' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
|
toast({
|
||||||
|
title: 'An error occurred',
|
||||||
|
description: err.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
description:
|
||||||
|
err.message ??
|
||||||
|
'We encountered an unknown error while attempting to delete your account. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<Alert
|
||||||
|
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<AlertTitle>Delete Account</AlertTitle>
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
Delete your account and all its contents, including completed documents. This action is
|
||||||
|
irreversible and will cancel your subscription, so proceed with caution.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive">Delete Account</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="space-y-4">
|
||||||
|
<DialogTitle>Delete Account</DialogTitle>
|
||||||
|
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription className="selection:bg-red-100">
|
||||||
|
This action is not reversible. Please be certain.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{hasTwoFactorAuthentication && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription className="selection:bg-red-100">
|
||||||
|
Disable Two Factor Authentication before deleting your account.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Documenso will delete <span className="font-semibold">all of your documents</span>
|
||||||
|
, along with all of your completed documents, signatures, and all other resources
|
||||||
|
belonging to your Account.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={onDeleteAccount}
|
||||||
|
loading={isDeletingAccount}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={hasTwoFactorAuthentication}
|
||||||
|
>
|
||||||
|
{isDeletingAccount ? 'Deleting account...' : 'Delete Account'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,6 +5,8 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { ProfileForm } from '~/components/forms/profile';
|
import { ProfileForm } from '~/components/forms/profile';
|
||||||
|
|
||||||
|
import { DeleteAccountDialog } from './delete-account-dialog';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Profile',
|
title: 'Profile',
|
||||||
};
|
};
|
||||||
@ -16,7 +18,9 @@ export default async function ProfileSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
||||||
|
|
||||||
<ProfileForm user={user} className="max-w-xl" />
|
<ProfileForm className="max-w-xl" user={user} />
|
||||||
|
|
||||||
|
<DeleteAccountDialog className="mt-8 max-w-xl" user={user} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
74
apps/web/src/app/(dashboard)/settings/tokens/page.tsx
Normal file
74
apps/web/src/app/(dashboard)/settings/tokens/page.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
import { ApiTokenForm } from '~/components/forms/token';
|
||||||
|
|
||||||
|
export default async function ApiTokensPage() {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const tokens = await getUserTokens({ userId: user.id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
On this page, you can create new API tokens and manage the existing ones.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<ApiTokenForm className="max-w-xl" />
|
||||||
|
|
||||||
|
<hr className="mb-4 mt-8" />
|
||||||
|
|
||||||
|
<h4 className="text-xl font-medium">Your existing tokens</h4>
|
||||||
|
|
||||||
|
{tokens.length === 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||||
|
Your tokens will be shown here once you create them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tokens.length > 0 && (
|
||||||
|
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<div key={token.id} className="border-border rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between gap-x-4">
|
||||||
|
<div>
|
||||||
|
<h5 className="text-base">{token.name}</h5>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
|
||||||
|
</p>
|
||||||
|
{token.expires ? (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Token doesn't have an expiration date
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DeleteTokenDialog token={token}>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</DeleteTokenDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,30 +1,31 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { AlertTriangle, Loader, Plus } from 'lucide-react';
|
import { AlertTriangle, Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { Template } from '@documenso/prisma/client';
|
import type { Recipient, Template } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
import { TemplateType } from '~/components/formatter/template-type';
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||||
import { DataTableTitle } from './data-table-title';
|
import { DataTableTitle } from './data-table-title';
|
||||||
|
import { UseTemplateDialog } from './use-template-dialog';
|
||||||
|
|
||||||
|
type TemplateWithRecipient = Template & {
|
||||||
|
Recipient: Recipient[];
|
||||||
|
};
|
||||||
|
|
||||||
type TemplatesDataTableProps = {
|
type TemplatesDataTableProps = {
|
||||||
templates: Template[];
|
templates: TemplateWithRecipient[];
|
||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@ -47,14 +48,6 @@ export const TemplatesDataTable = ({
|
|||||||
|
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
|
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate } =
|
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
updateSearchParams({
|
updateSearchParams({
|
||||||
@ -64,28 +57,6 @@ export const TemplatesDataTable = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUseButtonClick = async (templateId: number) => {
|
|
||||||
try {
|
|
||||||
const { id } = await createDocumentFromTemplate({
|
|
||||||
templateId,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Document created',
|
|
||||||
description: 'Your document has been created from the template successfully.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`${documentRootPath}/${id}`);
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'An error occurred while creating document from template.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{remaining.documents === 0 && (
|
{remaining.documents === 0 && (
|
||||||
@ -121,22 +92,13 @@ export const TemplatesDataTable = ({
|
|||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
accessorKey: 'actions',
|
accessorKey: 'actions',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isRowLoading = loadingStates[row.original.id];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<Button
|
<UseTemplateDialog
|
||||||
disabled={isRowLoading || remaining.documents === 0}
|
templateId={row.original.id}
|
||||||
loading={isRowLoading}
|
recipients={row.original.Recipient}
|
||||||
onClick={async () => {
|
documentRootPath={documentRootPath}
|
||||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
/>
|
||||||
await onUseButtonClick(row.original.id);
|
|
||||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
|
||||||
Use Template
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DataTableActionDropdown
|
<DataTableActionDropdown
|
||||||
row={row.original}
|
row={row.original}
|
||||||
|
|||||||
247
apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx
Normal file
247
apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||||
|
|
||||||
|
export type UseTemplateDialogProps = {
|
||||||
|
templateId: number;
|
||||||
|
recipients: Recipient[];
|
||||||
|
documentRootPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UseTemplateDialog({
|
||||||
|
recipients,
|
||||||
|
documentRootPath,
|
||||||
|
templateId,
|
||||||
|
}: UseTemplateDialogProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||||
|
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||||
|
defaultValues: {
|
||||||
|
recipients:
|
||||||
|
recipients.length > 0
|
||||||
|
? recipients.map((recipient) => ({
|
||||||
|
nativeId: recipient.id,
|
||||||
|
formId: String(recipient.id),
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
|
||||||
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
|
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
|
try {
|
||||||
|
const { id } = await createDocumentFromTemplate({
|
||||||
|
templateId,
|
||||||
|
teamId: team?.id,
|
||||||
|
recipients: data.recipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document created',
|
||||||
|
description: 'Your document has been created from the template successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`${documentRootPath}/${id}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while creating document from template.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
|
||||||
|
|
||||||
|
const { fields: formRecipients } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: 'recipients',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="cursor-pointer">
|
||||||
|
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Use Template
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Document Recipients</DialogTitle>
|
||||||
|
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
{formRecipients.map((recipient, index) => (
|
||||||
|
<div
|
||||||
|
key={recipient.id}
|
||||||
|
data-native-id={recipient.id}
|
||||||
|
className="flex flex-wrap items-end gap-x-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor={`recipient-${recipient.id}-email`}>
|
||||||
|
Email
|
||||||
|
<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`recipients.${index}.email`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id={`recipient-${recipient.id}-email`}
|
||||||
|
type="email"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`recipients.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id={`recipient-${recipient.id}-name`}
|
||||||
|
type="text"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[60px]">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`recipients.${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 className="w-full">
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="justify-end">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={isCreatingDocumentFromTemplate}
|
||||||
|
disabled={isCreatingDocumentFromTemplate}
|
||||||
|
onClick={onCreateDocumentFromTemplate}
|
||||||
|
>
|
||||||
|
Create Document
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -29,6 +29,7 @@ import { NameField } from './name-field';
|
|||||||
import { NoLongerAvailable } from './no-longer-available';
|
import { NoLongerAvailable } from './no-longer-available';
|
||||||
import { SigningProvider } from './provider';
|
import { SigningProvider } from './provider';
|
||||||
import { SignatureField } from './signature-field';
|
import { SignatureField } from './signature-field';
|
||||||
|
import { TextField } from './text-field';
|
||||||
|
|
||||||
export type SigningPageProps = {
|
export type SigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -168,6 +169,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
.with(FieldType.EMAIL, () => (
|
.with(FieldType.EMAIL, () => (
|
||||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
|
.with(FieldType.TEXT, () => (
|
||||||
|
<TextField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
.otherwise(() => null),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
|
|||||||
166
apps/web/src/app/(signing)/sign/[token]/text-field.tsx
Normal file
166
apps/web/src/app/(signing)/sign/[token]/text-field.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
|
export type TextFieldProps = {
|
||||||
|
field: FieldWithSignature;
|
||||||
|
recipient: Recipient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TextField = ({ field, recipient }: TextFieldProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
|
trpc.field.signFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
|
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||||
|
|
||||||
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
|
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
||||||
|
const [localText, setLocalCustomText] = useState('');
|
||||||
|
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCustomTextModal && !isLocalSignatureSet) {
|
||||||
|
setLocalCustomText('');
|
||||||
|
}
|
||||||
|
}, [showCustomTextModal, isLocalSignatureSet]);
|
||||||
|
|
||||||
|
const onSign = async () => {
|
||||||
|
try {
|
||||||
|
if (!localText) {
|
||||||
|
setIsLocalSignatureSet(false);
|
||||||
|
setShowCustomTextModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await signFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
value: localText,
|
||||||
|
isBase64: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalCustomText('');
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while signing the document.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
try {
|
||||||
|
await removeSignedFieldWithToken({
|
||||||
|
token: recipient.token,
|
||||||
|
fieldId: field.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while removing the text.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!field.inserted && (
|
||||||
|
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Text</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||||
|
|
||||||
|
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>
|
||||||
|
Enter your Text <span className="text-muted-foreground">({recipient.email})</span>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<div className="">
|
||||||
|
<Label htmlFor="custom-text">Custom Text</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="custom-text"
|
||||||
|
className="border-border mt-2 w-full rounded-md border"
|
||||||
|
onChange={(e) => setLocalCustomText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCustomTextModal(false);
|
||||||
|
setLocalCustomText('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!localText}
|
||||||
|
onClick={() => {
|
||||||
|
setShowCustomTextModal(false);
|
||||||
|
setIsLocalSignatureSet(true);
|
||||||
|
void onSign();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save Text
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</SigningFieldContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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} />;
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { SubscriptionStatus } from '@documenso/prisma/client';
|
|||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
|
import { TeamProvider } from '~/providers/team';
|
||||||
|
|
||||||
import { LayoutBillingBanner } from './layout-billing-banner';
|
import { LayoutBillingBanner } from './layout-billing-banner';
|
||||||
|
|
||||||
@ -56,7 +57,9 @@ export default async function AuthenticatedTeamsLayout({
|
|||||||
|
|
||||||
<Header user={user} teams={teams} />
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<TeamProvider team={team}>
|
||||||
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
</TeamProvider>
|
||||||
|
|
||||||
<RefreshOnFocus />
|
<RefreshOnFocus />
|
||||||
</LimitsProvider>
|
</LimitsProvider>
|
||||||
|
|||||||
@ -0,0 +1,85 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
import { ApiTokenForm } from '~/components/forms/token';
|
||||||
|
|
||||||
|
type ApiTokensPageProps = {
|
||||||
|
params: {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
|
||||||
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">API Tokens</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
On this page, you can create new API tokens and manage the existing ones.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<ApiTokenForm className="max-w-xl" teamId={team.id} />
|
||||||
|
|
||||||
|
<hr className="mb-4 mt-8" />
|
||||||
|
|
||||||
|
<h4 className="text-xl font-medium">Your existing tokens</h4>
|
||||||
|
|
||||||
|
{tokens.length === 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||||
|
Your tokens will be shown here once you create them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tokens.length > 0 && (
|
||||||
|
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<div key={token.id} className="border-border rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between gap-x-4">
|
||||||
|
<div>
|
||||||
|
<h5 className="text-base">{token.name}</h5>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
|
||||||
|
</p>
|
||||||
|
{token.expires ? (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Token doesn't have an expiration date
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DeleteTokenDialog token={token} teamId={team.id}>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</DeleteTokenDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
apps/web/src/app/api/v1/openapi/page.tsx
Normal file
3
apps/web/src/app/api/v1/openapi/page.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export { OpenApiDocsPage as default } from '@documenso/api/v1/api-documentation';
|
||||||
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>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
{teams.map((team) => (
|
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
||||||
<DropdownMenuItem asChild key={team.id}>
|
{teams.map((team) => (
|
||||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
<DropdownMenuItem asChild key={team.id}>
|
||||||
<AvatarWithText
|
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
||||||
avatarFallback={formatAvatarFallback(team.name)}
|
<AvatarWithText
|
||||||
primaryText={team.name}
|
avatarFallback={formatAvatarFallback(team.name)}
|
||||||
secondaryText={formatSecondaryAvatarText(team)}
|
primaryText={team.name}
|
||||||
rightSideComponent={
|
secondaryText={formatSecondaryAvatarText(team)}
|
||||||
isPathTeamUrl(team.url) && (
|
rightSideComponent={
|
||||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
isPathTeamUrl(team.url) && (
|
||||||
)
|
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
</Link>
|
/>
|
||||||
</DropdownMenuItem>
|
</Link>
|
||||||
))}
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Braces,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Lock,
|
Lock,
|
||||||
@ -98,6 +99,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings/tokens" className="cursor-pointer">
|
||||||
|
<Braces className="mr-2 h-4 w-4" />
|
||||||
|
API Tokens
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/billing" className="cursor-pointer">
|
<Link href="/settings/billing" className="cursor-pointer">
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -77,6 +77,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/tokens">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Braces className="mr-2 h-5 w-5" />
|
||||||
|
API Tokens
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -80,6 +80,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/tokens">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Braces className="mr-2 h-5 w-5" />
|
||||||
|
API Tokens
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{isBillingEnabled && (
|
{isBillingEnabled && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
export const EXPIRATION_DATES = {
|
||||||
|
ONE_WEEK: '7 days',
|
||||||
|
ONE_MONTH: '1 month',
|
||||||
|
THREE_MONTHS: '3 months',
|
||||||
|
SIX_MONTHS: '6 months',
|
||||||
|
ONE_YEAR: '12 months',
|
||||||
|
} as const;
|
||||||
@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { ApiToken } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DeleteTokenDialogProps = {
|
||||||
|
teamId?: number;
|
||||||
|
token: Pick<ApiToken, 'id' | 'name'>;
|
||||||
|
onDelete?: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteTokenDialog({
|
||||||
|
teamId,
|
||||||
|
token,
|
||||||
|
onDelete,
|
||||||
|
children,
|
||||||
|
}: DeleteTokenDialogProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const deleteMessage = `delete ${token.name}`;
|
||||||
|
|
||||||
|
const ZDeleteTokenDialogSchema = z.object({
|
||||||
|
tokenName: z.literal(deleteMessage, {
|
||||||
|
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenDialogSchema>;
|
||||||
|
|
||||||
|
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
onDelete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<TDeleteTokenByIdMutationSchema>({
|
||||||
|
resolver: zodResolver(ZDeleteTokenDialogSchema),
|
||||||
|
values: {
|
||||||
|
tokenName: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await deleteTokenMutation({
|
||||||
|
id: token.id,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Token deleted',
|
||||||
|
description: 'The token was deleted successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to delete this token. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [isOpen, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild={true}>
|
||||||
|
{children ?? (
|
||||||
|
<Button className="mr-4" variant="destructive">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure you want to delete this token?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Please note that this action is irreversible. Once confirmed, your token will be
|
||||||
|
permanently deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tokenName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Confirm by typing:{' '}
|
||||||
|
<span className="font-sm text-destructive font-semibold">
|
||||||
|
{deleteMessage}
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
I'm sure! Delete it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, usePathname } from 'next/navigation';
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Settings, Users } from 'lucide-react';
|
import { Braces, CreditCard, Settings, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -21,6 +21,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
|
|
||||||
const settingsPath = `/t/${teamUrl}/settings`;
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
|
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||||
const billingPath = `/t/${teamUrl}/settings/billing`;
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -48,6 +49,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href={tokensPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||||
|
>
|
||||||
|
<Braces className="mr-2 h-5 w-5" />
|
||||||
|
API Tokens
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{IS_BILLING_ENABLED() && (
|
{IS_BILLING_ENABLED() && (
|
||||||
<Link href={billingPath}>
|
<Link href={billingPath}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, usePathname } from 'next/navigation';
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
import { Braces, CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -21,6 +21,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
|
|
||||||
const settingsPath = `/t/${teamUrl}/settings`;
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
|
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||||
const billingPath = `/t/${teamUrl}/settings/billing`;
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -56,6 +57,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href={tokensPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||||
|
>
|
||||||
|
<Braces className="mr-2 h-5 w-5" />
|
||||||
|
API Tokens
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{IS_BILLING_ENABLED() && (
|
{IS_BILLING_ENABLED() && (
|
||||||
<Link href={billingPath}>
|
<Link href={billingPath}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -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;
|
color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
|
||||||
PENDING: {
|
PENDING: {
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@ -10,7 +10,7 @@ import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
|||||||
|
|
||||||
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
export type LocaleDateProps = HTMLAttributes<HTMLSpanElement> & {
|
||||||
date: string | number | Date;
|
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) => {
|
export const LocaleDate = ({ className, date, format, ...props }: LocaleDateProps) => {
|
||||||
const { locale } = useLocale();
|
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(() =>
|
const [localeDate, setLocaleDate] = useState(() =>
|
||||||
DateTime.fromJSDate(new Date(date)).setLocale(locale).toLocaleString(format),
|
formatDateTime(DateTime.fromJSDate(new Date(date)).setLocale(locale)),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocaleDate(DateTime.fromJSDate(new Date(date)).toLocaleString(format));
|
setLocaleDate(formatDateTime(DateTime.fromJSDate(new Date(date))));
|
||||||
}, [date, format]);
|
}, [date, format, formatDateTime]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={className} {...props}>
|
<span className={className} {...props}>
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { flushSync } from 'react-dom';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { renderSVG } from 'uqr';
|
import { renderSVG } from 'uqr';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -54,14 +52,16 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: EnableAuthenticatorAppDialogProps) => {
|
}: EnableAuthenticatorAppDialogProps) => {
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||||
trpc.twoFactorAuthentication.setup.useMutation();
|
trpc.twoFactorAuthentication.setup.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
|
const {
|
||||||
trpc.twoFactorAuthentication.enable.useMutation();
|
mutateAsync: enableTwoFactorAuthentication,
|
||||||
|
data: enableTwoFactorAuthenticationData,
|
||||||
|
isLoading: isEnableTwoFactorAuthenticationDataLoading,
|
||||||
|
} = trpc.twoFactorAuthentication.enable.useMutation();
|
||||||
|
|
||||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||||
defaultValues: {
|
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 ({
|
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
||||||
token,
|
token,
|
||||||
}: TEnableTwoFactorAuthenticationForm) => {
|
}: TEnableTwoFactorAuthenticationForm) => {
|
||||||
@ -136,14 +149,6 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCompleteClick = () => {
|
|
||||||
flushSync(() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
<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} />
|
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
||||||
<Button type="button" onClick={() => onCompleteClick()}>
|
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||||
Complete
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={downloadRecoveryCodes}
|
||||||
|
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
|
||||||
|
loading={isEnableTwoFactorAuthenticationDataLoading}
|
||||||
|
>
|
||||||
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -42,8 +43,11 @@ export type ViewRecoveryCodesDialogProps = {
|
|||||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
|
const {
|
||||||
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
mutateAsync: viewRecoveryCodes,
|
||||||
|
data: viewRecoveryCodesData,
|
||||||
|
isLoading: isViewRecoveryCodesDataLoading,
|
||||||
|
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||||
|
|
||||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -62,6 +66,19 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
|||||||
return 'view';
|
return 'view';
|
||||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
}, [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) => {
|
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
||||||
try {
|
try {
|
||||||
await viewRecoveryCodes({ password });
|
await viewRecoveryCodes({ password });
|
||||||
@ -139,8 +156,17 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
|||||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
<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 onClick={() => onOpenChange(false)}>Complete</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!viewRecoveryCodesData?.recoveryCodes}
|
||||||
|
loading={isViewRecoveryCodesDataLoading}
|
||||||
|
onClick={downloadRecoveryCodes}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -29,6 +29,11 @@ export const ZProfileFormSchema = z.object({
|
|||||||
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ZTwoFactorAuthTokenSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TTwoFactorAuthTokenSchema = z.infer<typeof ZTwoFactorAuthTokenSchema>;
|
||||||
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
|
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
|
||||||
|
|
||||||
export type ProfileFormProps = {
|
export type ProfileFormProps = {
|
||||||
@ -50,8 +55,11 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
||||||
|
|
||||||
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
||||||
|
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
|
||||||
|
trpc.profile.deleteAccount.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
||||||
try {
|
try {
|
||||||
@ -133,7 +141,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<Button type="submit" loading={isSubmitting}>
|
<Button type="submit" loading={isSubmitting} className="self-end">
|
||||||
{isSubmitting ? 'Updating profile...' : 'Update profile'}
|
{isSubmitting ? 'Updating profile...' : 'Update profile'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
257
apps/web/src/components/forms/token.tsx
Normal file
257
apps/web/src/components/forms/token.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
||||||
|
import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Switch } from '@documenso/ui/primitives/switch';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { EXPIRATION_DATES } from '../(dashboard)/settings/token/contants';
|
||||||
|
|
||||||
|
const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.extend({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
|
||||||
|
|
||||||
|
export type ApiTokenFormProps = {
|
||||||
|
className?: string;
|
||||||
|
teamId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApiTokenForm = ({ className, teamId }: ApiTokenFormProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [, copy] = useCopyToClipboard();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [newlyCreatedToken, setNewlyCreatedToken] = useState('');
|
||||||
|
const [noExpirationDate, setNoExpirationDate] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({
|
||||||
|
onSuccess(data) {
|
||||||
|
setNewlyCreatedToken(data.token);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<TCreateTokenFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreateTokenFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
tokenName: '',
|
||||||
|
expirationDate: '',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyToken = async (token: string) => {
|
||||||
|
try {
|
||||||
|
const copied = await copy(token);
|
||||||
|
|
||||||
|
if (!copied) {
|
||||||
|
throw new Error('Unable to copy the token');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Token copied to clipboard',
|
||||||
|
description: 'The token was copied to your clipboard.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Unable to copy token',
|
||||||
|
description: 'We were unable to copy the token to your clipboard. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
|
||||||
|
try {
|
||||||
|
await createTokenMutation({
|
||||||
|
teamId,
|
||||||
|
tokenName,
|
||||||
|
expirationDate: noExpirationDate ? null : expirationDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Token created',
|
||||||
|
description: 'A new token was created successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
|
||||||
|
toast({
|
||||||
|
title: 'An error occurred',
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting create the new token. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(className)}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset className="mt-6 flex w-full flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tokenName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel className="text-muted-foreground">Token name</FormLabel>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<FormControl className="flex-1">
|
||||||
|
<Input type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription className="text-xs italic">
|
||||||
|
Please enter a meaningful name for your token. This will help you identify it
|
||||||
|
later.
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="expirationDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel className="text-muted-foreground">Token expiration date</FormLabel>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<FormControl className="flex-1">
|
||||||
|
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Choose..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{date}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="">
|
||||||
|
<FormLabel className="text-muted-foreground mt-2">Never expire</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="block md:py-1.5">
|
||||||
|
<Switch
|
||||||
|
className="bg-background"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
setNoExpirationDate((prev) => !prev);
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="hidden md:inline-flex"
|
||||||
|
disabled={!form.formState.isDirty}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Create token
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!form.formState.isDirty}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Create token
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{newlyCreatedToken && (
|
||||||
|
<Card className="mt-8" gradient>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Your token was created successfully! Make sure to copy it because you won't be able to
|
||||||
|
see it again!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="bg-muted-foreground/10 my-4 rounded-md px-2.5 py-1 font-mono text-sm">
|
||||||
|
{newlyCreatedToken}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken)}>
|
||||||
|
Copy token
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
apps/web/src/pages/api/v1/[...ts-rest].tsx
Normal file
17
apps/web/src/pages/api/v1/[...ts-rest].tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { createNextRouter } from '@documenso/api/next';
|
||||||
|
import { ApiContractV1 } from '@documenso/api/v1/contract';
|
||||||
|
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
|
||||||
|
|
||||||
|
const nextRouteHandler = createNextRouter(ApiContractV1, ApiContractV1Implementation, {
|
||||||
|
responseValidation: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
// TODO: Dirty hack to make ts-rest handler work with next.js in a more intuitive way.
|
||||||
|
req.query['ts-rest'] = Array.isArray(req.query['ts-rest']) ? req.query['ts-rest'] : []; // Make `ts-rest` an array.
|
||||||
|
req.query['ts-rest'].unshift('api', 'v1'); // Prepend our base path to the array.
|
||||||
|
|
||||||
|
return await nextRouteHandler(req, res);
|
||||||
|
}
|
||||||
7
apps/web/src/pages/api/v1/openapi.json.ts
Normal file
7
apps/web/src/pages/api/v1/openapi.json.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
||||||
|
|
||||||
|
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
res.status(200).json(OpenAPIV1);
|
||||||
|
}
|
||||||
31
apps/web/src/providers/team.tsx
Normal file
31
apps/web/src/providers/team.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
interface TeamProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
team: Team;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamContext = createContext<Team | null>(null);
|
||||||
|
|
||||||
|
export const useCurrentTeam = (): Team | null => {
|
||||||
|
const context = useContext(TeamContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCurrentTeam must be used within a TeamProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOptionalCurrentTeam = (): Team | null => {
|
||||||
|
return useContext(TeamContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamProvider = ({ children, team }: TeamProviderProps) => {
|
||||||
|
return <TeamContext.Provider value={team}>{children}</TeamContext.Provider>;
|
||||||
|
};
|
||||||
1981
package-lock.json
generated
1981
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
1
packages/api/index.ts
Normal file
1
packages/api/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
1
packages/api/next.ts
Normal file
1
packages/api/next.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { createNextRouter } from '@ts-rest/next';
|
||||||
30
packages/api/package.json
Normal file
30
packages/api/package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@documenso/api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "./index.ts",
|
||||||
|
"types": "./index.ts",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"clean": "rimraf node_modules"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.ts",
|
||||||
|
"next.ts",
|
||||||
|
"v1/"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@documenso/lib": "*",
|
||||||
|
"@documenso/prisma": "*",
|
||||||
|
"@ts-rest/core": "^3.30.5",
|
||||||
|
"@ts-rest/next": "^3.30.5",
|
||||||
|
"@ts-rest/open-api": "^3.33.0",
|
||||||
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
|
"superjson": "^1.13.1",
|
||||||
|
"swagger-ui-react": "^5.11.0",
|
||||||
|
"ts-pattern": "^5.0.5",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/api/tsconfig.json
Normal file
8
packages/api/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@documenso/tsconfig/react-library.json",
|
||||||
|
"include": ["."],
|
||||||
|
"exclude": ["dist", "build", "node_modules"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/api/v1/api-documentation.tsx
Normal file
10
packages/api/v1/api-documentation.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import SwaggerUI from 'swagger-ui-react';
|
||||||
|
import 'swagger-ui-react/swagger-ui.css';
|
||||||
|
|
||||||
|
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
||||||
|
|
||||||
|
export const OpenApiDocsPage = () => {
|
||||||
|
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
|
||||||
|
};
|
||||||
191
packages/api/v1/contract.ts
Normal file
191
packages/api/v1/contract.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { initContract } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ZAuthorizationHeadersSchema,
|
||||||
|
ZCreateDocumentFromTemplateMutationResponseSchema,
|
||||||
|
ZCreateDocumentFromTemplateMutationSchema,
|
||||||
|
ZCreateDocumentMutationResponseSchema,
|
||||||
|
ZCreateDocumentMutationSchema,
|
||||||
|
ZCreateFieldMutationSchema,
|
||||||
|
ZCreateRecipientMutationSchema,
|
||||||
|
ZDeleteDocumentMutationSchema,
|
||||||
|
ZDeleteFieldMutationSchema,
|
||||||
|
ZDeleteRecipientMutationSchema,
|
||||||
|
ZGetDocumentsQuerySchema,
|
||||||
|
ZSendDocumentForSigningMutationSchema,
|
||||||
|
ZSuccessfulDocumentResponseSchema,
|
||||||
|
ZSuccessfulFieldResponseSchema,
|
||||||
|
ZSuccessfulGetDocumentResponseSchema,
|
||||||
|
ZSuccessfulRecipientResponseSchema,
|
||||||
|
ZSuccessfulResponseSchema,
|
||||||
|
ZSuccessfulSigningResponseSchema,
|
||||||
|
ZUnsuccessfulResponseSchema,
|
||||||
|
ZUpdateFieldMutationSchema,
|
||||||
|
ZUpdateRecipientMutationSchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
|
const c = initContract();
|
||||||
|
|
||||||
|
export const ApiContractV1 = c.router(
|
||||||
|
{
|
||||||
|
getDocuments: {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/documents',
|
||||||
|
query: ZGetDocumentsQuerySchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Get all documents',
|
||||||
|
},
|
||||||
|
|
||||||
|
getDocument: {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/documents/:id',
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulGetDocumentResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Get a single document',
|
||||||
|
},
|
||||||
|
|
||||||
|
createDocument: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/documents',
|
||||||
|
body: ZCreateDocumentMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZCreateDocumentMutationResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Upload a new document and get a presigned URL',
|
||||||
|
},
|
||||||
|
|
||||||
|
createDocumentFromTemplate: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/templates/:templateId/create-document',
|
||||||
|
body: ZCreateDocumentFromTemplateMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZCreateDocumentFromTemplateMutationResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Create a new document from an existing template',
|
||||||
|
},
|
||||||
|
|
||||||
|
sendDocument: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/documents/:id/send',
|
||||||
|
body: ZSendDocumentForSigningMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulSigningResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Send a document for signing',
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDocument: {
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/v1/documents/:id',
|
||||||
|
body: ZDeleteDocumentMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulDocumentResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Delete a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
createRecipient: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/documents/:id/recipients',
|
||||||
|
body: ZCreateRecipientMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulRecipientResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Create a recipient for a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRecipient: {
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/v1/documents/:id/recipients/:recipientId',
|
||||||
|
body: ZUpdateRecipientMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulRecipientResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Update a recipient for a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRecipient: {
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/v1/documents/:id/recipients/:recipientId',
|
||||||
|
body: ZDeleteRecipientMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulRecipientResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Delete a recipient from a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
createField: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/documents/:id/fields',
|
||||||
|
body: ZCreateFieldMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulFieldResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Create a field for a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
updateField: {
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/api/v1/documents/:id/fields/:fieldId',
|
||||||
|
body: ZUpdateFieldMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulFieldResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Update a field for a document',
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteField: {
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/api/v1/documents/:id/fields/:fieldId',
|
||||||
|
body: ZDeleteFieldMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZSuccessfulFieldResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Delete a field from a document',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
baseHeaders: ZAuthorizationHeadersSchema,
|
||||||
|
},
|
||||||
|
);
|
||||||
59
packages/api/v1/examples/01-create-and-send-document.ts
Normal file
59
packages/api/v1/examples/01-create-and-send-document.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status, body } = await client.createDocument({
|
||||||
|
body: {
|
||||||
|
title: 'My Document',
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'SIGNER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Jane Doe',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
role: 'APPROVER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
subject: 'Please sign this document',
|
||||||
|
message: 'Hey {signer.name}, please sign the following document: {document.name}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to create document');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uploadUrl, documentId } = body;
|
||||||
|
|
||||||
|
await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
body: '<raw-binary-data>',
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.sendDocument({
|
||||||
|
params: {
|
||||||
|
id: documentId.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
43
packages/api/v1/examples/02-add-a-field.ts
Normal file
43
packages/api/v1/examples/02-add-a-field.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const recipientId = 1;
|
||||||
|
|
||||||
|
const { status, body } = await client.createField({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: 'SIGNATURE',
|
||||||
|
pageHeight: 2.5, // percent of page to occupy in height
|
||||||
|
pageWidth: 5, // percent of page to occupy in width
|
||||||
|
pageX: 10, // percent from left
|
||||||
|
pageY: 10, // percent from top
|
||||||
|
pageNumber: 1,
|
||||||
|
recipientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to create field');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: fieldId } = body;
|
||||||
|
|
||||||
|
console.log(`Field created with id: ${fieldId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
39
packages/api/v1/examples/03-update-a-field.ts
Normal file
39
packages/api/v1/examples/03-update-a-field.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const fieldId = '1';
|
||||||
|
|
||||||
|
const { status } = await client.updateField({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
fieldId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: 'SIGNATURE',
|
||||||
|
pageHeight: 2.5, // percent of page to occupy in height
|
||||||
|
pageWidth: 5, // percent of page to occupy in width
|
||||||
|
pageX: 10, // percent from left
|
||||||
|
pageY: 10, // percent from top
|
||||||
|
pageNumber: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to update field');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
31
packages/api/v1/examples/04-remove-a-field.ts
Normal file
31
packages/api/v1/examples/04-remove-a-field.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const fieldId = '1';
|
||||||
|
|
||||||
|
const { status } = await client.deleteField({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
fieldId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to remove field');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
38
packages/api/v1/examples/05-add-a-recipient.ts
Normal file
38
packages/api/v1/examples/05-add-a-recipient.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
|
||||||
|
const { status, body } = await client.createRecipient({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'APPROVER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to add recipient');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: recipientId } = body;
|
||||||
|
|
||||||
|
console.log(`Recipient added with id: ${recipientId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
34
packages/api/v1/examples/06-update-a-recipient.ts
Normal file
34
packages/api/v1/examples/06-update-a-recipient.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const recipientId = '1';
|
||||||
|
|
||||||
|
const { status } = await client.updateRecipient({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
recipientId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Johnathon Doe',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to update recipient');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
31
packages/api/v1/examples/07-remove-a-recipient.ts
Normal file
31
packages/api/v1/examples/07-remove-a-recipient.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
const recipientId = '1';
|
||||||
|
|
||||||
|
const { status } = await client.deleteRecipient({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
recipientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to update recipient');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
31
packages/api/v1/examples/08-get-a-document.ts
Normal file
31
packages/api/v1/examples/08-get-a-document.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = '1';
|
||||||
|
|
||||||
|
const { status, body } = await client.getDocument({
|
||||||
|
params: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to get document');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Got document with id: ${documentId} and title: ${body.title}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
37
packages/api/v1/examples/09-paginate-all-documents.ts
Normal file
37
packages/api/v1/examples/09-paginate-all-documents.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { initClient } from '@ts-rest/core';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from '../contract';
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const client = initClient(ApiContractV1, {
|
||||||
|
baseUrl: 'http://localhost:3000/api/v1',
|
||||||
|
baseHeaders: {
|
||||||
|
authorization: 'Bearer <my-token>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = 1;
|
||||||
|
const perPage = 10;
|
||||||
|
|
||||||
|
const { status, body } = await client.getDocuments({
|
||||||
|
query: {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error('Failed to get documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const document of body.documents) {
|
||||||
|
console.log(`Got document with id: ${document.id} and title: ${document.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Total documents: ${body.totalPages * perPage}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
800
packages/api/v1/implementation.ts
Normal file
800
packages/api/v1/implementation.ts
Normal file
@ -0,0 +1,800 @@
|
|||||||
|
import { createNextRoute } from '@ts-rest/next';
|
||||||
|
|
||||||
|
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||||
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
|
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||||
|
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||||
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
|
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||||
|
import { createField } from '@documenso/lib/server-only/field/create-field';
|
||||||
|
import { deleteField } from '@documenso/lib/server-only/field/delete-field';
|
||||||
|
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
|
||||||
|
import { updateField } from '@documenso/lib/server-only/field/update-field';
|
||||||
|
import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient';
|
||||||
|
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
|
||||||
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
|
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
|
||||||
|
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||||
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||||
|
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from './contract';
|
||||||
|
import { authenticatedMiddleware } from './middleware/authenticated';
|
||||||
|
|
||||||
|
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||||
|
getDocuments: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const page = Number(args.query.page) || 1;
|
||||||
|
const perPage = Number(args.query.perPage) || 10;
|
||||||
|
|
||||||
|
const { data: documents, totalPages } = await findDocuments({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
documents,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id: documentId } = args.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipients = await getRecipientsForDocument({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
teamId: team?.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...document,
|
||||||
|
recipients,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id: documentId } = args.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedDocument = await deleteDocument({
|
||||||
|
id: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: deletedDocument,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { body } = args;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
message: 'Create document is not available without S3 transport.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
||||||
|
|
||||||
|
if (remaining.documents <= 0) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'You have reached the maximum number of documents allowed for this month',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
|
||||||
|
|
||||||
|
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
|
||||||
|
|
||||||
|
const documentData = await createDocumentData({
|
||||||
|
data: key,
|
||||||
|
type: DocumentDataType.S3_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await createDocument({
|
||||||
|
title: body.title,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
documentDataId: documentData.id,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipients = await setRecipientsForDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
documentId: document.id,
|
||||||
|
recipients: body.recipients,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
uploadUrl: url,
|
||||||
|
documentId: document.id,
|
||||||
|
recipients: recipients.map((recipient) => ({
|
||||||
|
recipientId: recipient.id,
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
token: recipient.token,
|
||||||
|
role: recipient.role,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'An error has occured while uploading the file',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { body, params } = args;
|
||||||
|
|
||||||
|
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
||||||
|
|
||||||
|
if (remaining.documents <= 0) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'You have reached the maximum number of documents allowed for this month',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateId = Number(params.templateId);
|
||||||
|
|
||||||
|
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
|
||||||
|
|
||||||
|
const document = await createDocumentFromTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
recipients: body.recipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
data: {
|
||||||
|
title: fileName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (body.meta) {
|
||||||
|
await upsertDocumentMeta({
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
subject: body.meta.subject,
|
||||||
|
message: body.meta.message,
|
||||||
|
dateFormat: body.meta.dateFormat,
|
||||||
|
timezone: body.meta.timezone,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
documentId: document.id,
|
||||||
|
recipients: document.Recipient.map((recipient) => ({
|
||||||
|
recipientId: recipient.id,
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
token: recipient.token,
|
||||||
|
role: recipient.role,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
sendDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id } = args.params;
|
||||||
|
|
||||||
|
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already complete',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// await setRecipientsForDocument({
|
||||||
|
// userId: user.id,
|
||||||
|
// documentId: Number(id),
|
||||||
|
// recipients: [
|
||||||
|
// {
|
||||||
|
// email: body.signerEmail,
|
||||||
|
// name: body.signerName ?? '',
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// await setFieldsForDocument({
|
||||||
|
// documentId: Number(id),
|
||||||
|
// userId: user.id,
|
||||||
|
// fields: body.fields.map((field) => ({
|
||||||
|
// signerEmail: body.signerEmail,
|
||||||
|
// type: field.fieldType,
|
||||||
|
// pageNumber: field.pageNumber,
|
||||||
|
// pageX: field.pageX,
|
||||||
|
// pageY: field.pageY,
|
||||||
|
// pageWidth: field.pageWidth,
|
||||||
|
// pageHeight: field.pageHeight,
|
||||||
|
// })),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (body.emailBody || body.emailSubject) {
|
||||||
|
// await upsertDocumentMeta({
|
||||||
|
// documentId: Number(id),
|
||||||
|
// subject: body.emailSubject ?? '',
|
||||||
|
// message: body.emailBody ?? '',
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
await sendDocument({
|
||||||
|
documentId: Number(id),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
message: 'Document sent for signing successfully',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
message: 'An error has occured while sending the document for signing',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createRecipient: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id: documentId } = args.params;
|
||||||
|
const { name, email, role } = args.body;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = await getRecipientsForDocument({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email);
|
||||||
|
|
||||||
|
if (recipientAlreadyExists) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient already exists',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newRecipients = await setRecipientsForDocument({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
recipients: [
|
||||||
|
...recipients,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
});
|
||||||
|
|
||||||
|
const newRecipient = newRecipients.find((recipient) => recipient.email === email);
|
||||||
|
|
||||||
|
if (!newRecipient) {
|
||||||
|
throw new Error('Recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...newRecipient,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
message: 'An error has occured while creating the recipient',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateRecipient: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id: documentId, recipientId } = args.params;
|
||||||
|
const { name, email, role } = args.body;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRecipient = await updateRecipient({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
recipientId: Number(recipientId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!updatedRecipient) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...updatedRecipient,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteRecipient: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id: documentId, recipientId } = args.params;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedRecipient = await deleteRecipient({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
recipientId: Number(recipientId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!deletedRecipient) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Unable to delete recipient',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...deletedRecipient,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
createField: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id: documentId } = args.params;
|
||||||
|
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = await getRecipientById({
|
||||||
|
id: Number(recipientId),
|
||||||
|
documentId: Number(documentId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient has already signed the document',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = await createField({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
recipientId: Number(recipientId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
type,
|
||||||
|
pageNumber,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remappedField = {
|
||||||
|
id: field.id,
|
||||||
|
documentId: field.documentId,
|
||||||
|
recipientId: field.recipientId ?? -1,
|
||||||
|
type: field.type,
|
||||||
|
pageNumber: field.page,
|
||||||
|
pageX: Number(field.positionX),
|
||||||
|
pageY: Number(field.positionY),
|
||||||
|
pageWidth: Number(field.width),
|
||||||
|
pageHeight: Number(field.height),
|
||||||
|
customText: field.customText,
|
||||||
|
inserted: field.inserted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...remappedField,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateField: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id: documentId, fieldId } = args.params;
|
||||||
|
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = await getRecipientById({
|
||||||
|
id: Number(recipientId),
|
||||||
|
documentId: Number(documentId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient has already signed the document',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedField = await updateField({
|
||||||
|
fieldId: Number(fieldId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
recipientId: recipientId ? Number(recipientId) : undefined,
|
||||||
|
type,
|
||||||
|
pageNumber,
|
||||||
|
pageX,
|
||||||
|
pageY,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remappedField = {
|
||||||
|
id: updatedField.id,
|
||||||
|
documentId: updatedField.documentId,
|
||||||
|
recipientId: updatedField.recipientId ?? -1,
|
||||||
|
type: updatedField.type,
|
||||||
|
pageNumber: updatedField.page,
|
||||||
|
pageX: Number(updatedField.positionX),
|
||||||
|
pageY: Number(updatedField.positionY),
|
||||||
|
pageWidth: Number(updatedField.width),
|
||||||
|
pageHeight: Number(updatedField.height),
|
||||||
|
customText: updatedField.customText,
|
||||||
|
inserted: updatedField.inserted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...remappedField,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteField: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { id: documentId, fieldId } = args.params;
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: Number(documentId),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Document not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Document is already completed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = await getFieldById({
|
||||||
|
fieldId: Number(fieldId),
|
||||||
|
documentId: Number(documentId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!field) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
message: 'Field not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = await getRecipientById({
|
||||||
|
id: Number(field.recipientId),
|
||||||
|
documentId: Number(documentId),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Recipient has already signed the document',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedField = await deleteField({
|
||||||
|
documentId: Number(documentId),
|
||||||
|
fieldId: Number(fieldId),
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!deletedField) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Unable to delete field',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const remappedField = {
|
||||||
|
id: deletedField.id,
|
||||||
|
documentId: deletedField.documentId,
|
||||||
|
recipientId: deletedField.recipientId ?? -1,
|
||||||
|
type: deletedField.type,
|
||||||
|
pageNumber: deletedField.page,
|
||||||
|
pageX: Number(deletedField.positionX),
|
||||||
|
pageY: Number(deletedField.positionY),
|
||||||
|
pageWidth: Number(deletedField.width),
|
||||||
|
pageHeight: Number(deletedField.height),
|
||||||
|
customText: deletedField.customText,
|
||||||
|
inserted: deletedField.inserted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
...remappedField,
|
||||||
|
documentId: Number(documentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
41
packages/api/v1/middleware/authenticated.ts
Normal file
41
packages/api/v1/middleware/authenticated.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { NextApiRequest } from 'next';
|
||||||
|
|
||||||
|
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||||
|
import type { Team, User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const authenticatedMiddleware = <
|
||||||
|
T extends {
|
||||||
|
req: NextApiRequest;
|
||||||
|
},
|
||||||
|
R extends {
|
||||||
|
status: number;
|
||||||
|
body: unknown;
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
handler: (args: T, user: User, team?: Team | null) => Promise<R>,
|
||||||
|
) => {
|
||||||
|
return async (args: T) => {
|
||||||
|
try {
|
||||||
|
const { authorization } = args.req.headers;
|
||||||
|
|
||||||
|
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||||
|
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Token was not provided for authenticated middleware');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiToken = await getApiTokenByToken({ token });
|
||||||
|
|
||||||
|
return await handler(args, apiToken.user, apiToken.team);
|
||||||
|
} catch (_err) {
|
||||||
|
console.log({ _err });
|
||||||
|
return {
|
||||||
|
status: 401,
|
||||||
|
body: {
|
||||||
|
message: 'Unauthorized',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
17
packages/api/v1/openapi.ts
Normal file
17
packages/api/v1/openapi.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { generateOpenApi } from '@ts-rest/open-api';
|
||||||
|
|
||||||
|
import { ApiContractV1 } from './contract';
|
||||||
|
|
||||||
|
export const OpenAPIV1 = generateOpenApi(
|
||||||
|
ApiContractV1,
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
title: 'Documenso API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setOperationId: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
241
packages/api/v1/schema.ts
Normal file
241
packages/api/v1/schema.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FieldType,
|
||||||
|
ReadStatus,
|
||||||
|
RecipientRole,
|
||||||
|
SendStatus,
|
||||||
|
SigningStatus,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents
|
||||||
|
*/
|
||||||
|
export const ZGetDocumentsQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().min(1).optional().default(1),
|
||||||
|
perPage: z.coerce.number().min(1).optional().default(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
|
||||||
|
|
||||||
|
export const ZDeleteDocumentMutationSchema = null;
|
||||||
|
|
||||||
|
export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema;
|
||||||
|
|
||||||
|
export const ZSuccessfulDocumentResponseSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
userId: z.number(),
|
||||||
|
teamId: z.number().nullish(),
|
||||||
|
title: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
documentDataId: z.string(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
completedAt: z.date().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseSchema.extend({
|
||||||
|
recipients: z.lazy(() => z.array(ZSuccessfulRecipientResponseSchema)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulGetDocumentResponseSchema = z.infer<
|
||||||
|
typeof ZSuccessfulGetDocumentResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
|
||||||
|
|
||||||
|
export const ZSendDocumentForSigningMutationSchema = null;
|
||||||
|
|
||||||
|
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||||
|
|
||||||
|
export const ZUploadDocumentSuccessfulSchema = z.object({
|
||||||
|
url: z.string(),
|
||||||
|
key: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
|
||||||
|
|
||||||
|
export const ZCreateDocumentMutationSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
meta: z
|
||||||
|
.object({
|
||||||
|
subject: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
timezone: z.string(),
|
||||||
|
dateFormat: z.string(),
|
||||||
|
redirectUrl: z.string(),
|
||||||
|
})
|
||||||
|
.partial(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
||||||
|
|
||||||
|
export const ZCreateDocumentMutationResponseSchema = z.object({
|
||||||
|
uploadUrl: z.string().min(1),
|
||||||
|
documentId: z.number(),
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
recipientId: z.number(),
|
||||||
|
token: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateDocumentMutationResponseSchema = z.infer<
|
||||||
|
typeof ZCreateDocumentMutationResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
meta: z
|
||||||
|
.object({
|
||||||
|
subject: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
timezone: z.string(),
|
||||||
|
dateFormat: z.string(),
|
||||||
|
redirectUrl: z.string(),
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||||
|
typeof ZCreateDocumentFromTemplateMutationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||||
|
documentId: z.number(),
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
recipientId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
token: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||||
|
typeof ZCreateDocumentFromTemplateMutationResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ZCreateRecipientMutationSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipients
|
||||||
|
*/
|
||||||
|
export type TCreateRecipientMutationSchema = z.infer<typeof ZCreateRecipientMutationSchema>;
|
||||||
|
|
||||||
|
export const ZUpdateRecipientMutationSchema = ZCreateRecipientMutationSchema.partial();
|
||||||
|
|
||||||
|
export type TUpdateRecipientMutationSchema = z.infer<typeof ZUpdateRecipientMutationSchema>;
|
||||||
|
|
||||||
|
export const ZDeleteRecipientMutationSchema = null;
|
||||||
|
|
||||||
|
export type TDeleteRecipientMutationSchema = typeof ZDeleteRecipientMutationSchema;
|
||||||
|
|
||||||
|
export const ZSuccessfulRecipientResponseSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
// !: This handles the fact that we have null documentId's for templates
|
||||||
|
// !: while we won't need the default we must add it to satisfy typescript
|
||||||
|
documentId: z.number().nullish().default(-1),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
name: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
token: z.string(),
|
||||||
|
// !: Not used for now
|
||||||
|
// expired: z.string(),
|
||||||
|
signedAt: z.date().nullable(),
|
||||||
|
readStatus: z.nativeEnum(ReadStatus),
|
||||||
|
signingStatus: z.nativeEnum(SigningStatus),
|
||||||
|
sendStatus: z.nativeEnum(SendStatus),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields
|
||||||
|
*/
|
||||||
|
export const ZCreateFieldMutationSchema = z.object({
|
||||||
|
recipientId: z.number(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
pageNumber: z.number(),
|
||||||
|
pageX: z.number(),
|
||||||
|
pageY: z.number(),
|
||||||
|
pageWidth: z.number(),
|
||||||
|
pageHeight: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateFieldMutationSchema = z.infer<typeof ZCreateFieldMutationSchema>;
|
||||||
|
|
||||||
|
export const ZUpdateFieldMutationSchema = ZCreateFieldMutationSchema.partial();
|
||||||
|
|
||||||
|
export type TUpdateFieldMutationSchema = z.infer<typeof ZUpdateFieldMutationSchema>;
|
||||||
|
|
||||||
|
export const ZDeleteFieldMutationSchema = null;
|
||||||
|
|
||||||
|
export type TDeleteFieldMutationSchema = typeof ZDeleteFieldMutationSchema;
|
||||||
|
|
||||||
|
export const ZSuccessfulFieldResponseSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
documentId: z.number(),
|
||||||
|
recipientId: z.number(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
pageNumber: z.number(),
|
||||||
|
pageX: z.number(),
|
||||||
|
pageY: z.number(),
|
||||||
|
pageWidth: z.number(),
|
||||||
|
pageHeight: z.number(),
|
||||||
|
customText: z.string(),
|
||||||
|
inserted: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulFieldResponseSchema = z.infer<typeof ZSuccessfulFieldResponseSchema>;
|
||||||
|
|
||||||
|
export const ZSuccessfulResponseSchema = z.object({
|
||||||
|
documents: ZSuccessfulDocumentResponseSchema.array(),
|
||||||
|
totalPages: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
||||||
|
|
||||||
|
export const ZSuccessfulSigningResponseSchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General
|
||||||
|
*/
|
||||||
|
export const ZAuthorizationHeadersSchema = z.object({
|
||||||
|
authorization: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAuthorizationHeadersSchema = z.infer<typeof ZAuthorizationHeadersSchema>;
|
||||||
|
|
||||||
|
export const ZUnsuccessfulResponseSchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TUnsuccessfulResponseSchema = z.infer<typeof ZUnsuccessfulResponseSchema>;
|
||||||
@ -15,7 +15,7 @@ test('[PR-713]: should see sent documents', async ({ page }) => {
|
|||||||
|
|
||||||
await page.keyboard.press('Meta+K');
|
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();
|
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.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();
|
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.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();
|
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -107,6 +107,8 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
|
|||||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
await unseedTeam(team.url);
|
await unseedTeam(team.url);
|
||||||
@ -187,15 +189,18 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
|||||||
|
|
||||||
// Use personal template.
|
// Use personal template.
|
||||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Create Document' }).click();
|
||||||
await page.waitForURL(/documents/);
|
await page.waitForURL(/documents/);
|
||||||
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||||
await page.waitForURL('/documents');
|
await page.waitForURL('/documents');
|
||||||
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||||
|
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Use team template.
|
// Use team template.
|
||||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Create Document' }).click();
|
||||||
await page.waitForURL(/\/t\/.+\/documents/);
|
await page.waitForURL(/\/t\/.+\/documents/);
|
||||||
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||||
await page.waitForURL(`/t/${team.url}/documents`);
|
await page.waitForURL(`/t/${team.url}/documents`);
|
||||||
|
|||||||
@ -1,20 +1,19 @@
|
|||||||
import { type Page, expect, test } from '@playwright/test';
|
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: [] } });
|
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 }) => {
|
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.goto('/signup');
|
||||||
await page.getByLabel('Name').fill(username);
|
await page.getByLabel('Name').fill(username);
|
||||||
await page.getByLabel('Email').fill(email);
|
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.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 page.waitForURL('/documents');
|
||||||
|
|
||||||
await expect(page).toHaveURL('/documents');
|
await expect(page).toHaveURL('/documents');
|
||||||
|
await unseedUserByEmail(email);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('user can login with user and password', async ({ page }: { page: Page }) => {
|
test('user can login with user and password', async ({ page }: { page: Page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
await page.goto('/signin');
|
await page.goto('/signin');
|
||||||
await page.getByLabel('Email').fill(email);
|
await page.getByLabel('Email').fill(user.email);
|
||||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
await page.waitForURL('/documents');
|
await page.waitForURL('/documents');
|
||||||
await expect(page).toHaveURL('/documents');
|
await expect(page).toHaveURL('/documents');
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll('Teardown', async () => {
|
await unseedUser(user.id);
|
||||||
try {
|
|
||||||
await deleteUser({ email });
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`Error deleting user: ${e}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
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 type { DocumentData } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { getFile } from '../universal/upload/get-file';
|
import { getFile } from '../universal/upload/get-file';
|
||||||
|
import { downloadFile } from './download-file';
|
||||||
|
|
||||||
type DownloadPDFProps = {
|
type DownloadPDFProps = {
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
@ -14,16 +15,12 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
|
|||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = window.document.createElement('a');
|
|
||||||
|
|
||||||
const [baseTitle] = fileName?.includes('.pdf')
|
const [baseTitle] = fileName?.includes('.pdf')
|
||||||
? fileName.split('.pdf')
|
? fileName.split('.pdf')
|
||||||
: [fileName ?? 'document'];
|
: [fileName ?? 'document'];
|
||||||
|
|
||||||
link.href = window.URL.createObjectURL(blob);
|
downloadFile({
|
||||||
link.download = `${baseTitle}_signed.pdf`;
|
filename: baseTitle,
|
||||||
|
data: blob,
|
||||||
link.click();
|
});
|
||||||
|
|
||||||
window.URL.revokeObjectURL(link.href);
|
|
||||||
};
|
};
|
||||||
|
|||||||
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> = {
|
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||||
|
app_teams: true,
|
||||||
|
app_document_page_view_history_sheet: false,
|
||||||
marketing_header_single_player_mode: false,
|
marketing_header_single_player_mode: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@ -1,29 +1,31 @@
|
|||||||
import { RecipientRole } from '@documenso/prisma/client';
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const RECIPIENT_ROLES_DESCRIPTION: {
|
export const RECIPIENT_ROLES_DESCRIPTION = {
|
||||||
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
|
|
||||||
} = {
|
|
||||||
[RecipientRole.APPROVER]: {
|
[RecipientRole.APPROVER]: {
|
||||||
actionVerb: 'Approve',
|
actionVerb: 'Approve',
|
||||||
|
actioned: 'Approved',
|
||||||
progressiveVerb: 'Approving',
|
progressiveVerb: 'Approving',
|
||||||
roleName: 'Approver',
|
roleName: 'Approver',
|
||||||
},
|
},
|
||||||
[RecipientRole.CC]: {
|
[RecipientRole.CC]: {
|
||||||
actionVerb: 'CC',
|
actionVerb: 'CC',
|
||||||
|
actioned: `CC'd`,
|
||||||
progressiveVerb: 'CC',
|
progressiveVerb: 'CC',
|
||||||
roleName: 'CC',
|
roleName: 'Cc',
|
||||||
},
|
},
|
||||||
[RecipientRole.SIGNER]: {
|
[RecipientRole.SIGNER]: {
|
||||||
actionVerb: 'Sign',
|
actionVerb: 'Sign',
|
||||||
|
actioned: 'Signed',
|
||||||
progressiveVerb: 'Signing',
|
progressiveVerb: 'Signing',
|
||||||
roleName: 'Signer',
|
roleName: 'Signer',
|
||||||
},
|
},
|
||||||
[RecipientRole.VIEWER]: {
|
[RecipientRole.VIEWER]: {
|
||||||
actionVerb: 'View',
|
actionVerb: 'View',
|
||||||
|
actioned: 'Viewed',
|
||||||
progressiveVerb: 'Viewing',
|
progressiveVerb: 'Viewing',
|
||||||
roleName: 'Viewer',
|
roleName: 'Viewer',
|
||||||
},
|
},
|
||||||
};
|
} satisfies Record<keyof typeof RecipientRole, unknown>;
|
||||||
|
|
||||||
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||||
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
|
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
|
import { Duration } from 'luxon';
|
||||||
|
|
||||||
export const ONE_SECOND = 1000;
|
export const ONE_SECOND = 1000;
|
||||||
export const ONE_MINUTE = ONE_SECOND * 60;
|
export const ONE_MINUTE = ONE_SECOND * 60;
|
||||||
export const ONE_HOUR = ONE_MINUTE * 60;
|
export const ONE_HOUR = ONE_MINUTE * 60;
|
||||||
export const ONE_DAY = ONE_HOUR * 24;
|
export const ONE_DAY = ONE_HOUR * 24;
|
||||||
export const ONE_WEEK = ONE_DAY * 7;
|
export const ONE_WEEK = ONE_DAY * 7;
|
||||||
|
export const ONE_MONTH = Duration.fromObject({ months: 1 });
|
||||||
|
export const THREE_MONTHS = Duration.fromObject({ months: 3 });
|
||||||
|
export const SIX_MONTHS = Duration.fromObject({ months: 6 });
|
||||||
|
export const ONE_YEAR = Duration.fromObject({ years: 1 });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { ErrorCode } from '../../next-auth/error-codes';
|
import { ErrorCode } from '../../next-auth/error-codes';
|
||||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { base32 } from '@scure/base';
|
import { base32 } from '@scure/base';
|
||||||
import { TOTPController } from 'oslo/otp';
|
import { TOTPController } from 'oslo/otp';
|
||||||
|
|
||||||
import { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
import { symmetricDecrypt } from '../../universal/crypto';
|
import { symmetricDecrypt } from '../../universal/crypto';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { Prisma } from '@documenso/prisma/client';
|
import type { Prisma } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export interface FindDocumentsOptions {
|
export interface FindDocumentsOptions {
|
||||||
term?: string;
|
term?: string;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { Role } from '@documenso/prisma/client';
|
import type { Role } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type UpdateUserOptions = {
|
export type UpdateUserOptions = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
import { SALT_ROUNDS } from '../../constants/auth';
|
||||||
|
|
||||||
@ -12,3 +13,7 @@ export const hashSync = (password: string) => {
|
|||||||
export const compareSync = (password: string, hash: string) => {
|
export const compareSync = (password: string, hash: string) => {
|
||||||
return bcryptCompareSync(password, hash);
|
return bcryptCompareSync(password, hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const hashString = (input: string) => {
|
||||||
|
return crypto.createHash('sha512').update(input).digest('hex');
|
||||||
|
};
|
||||||
|
|||||||
@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.documentAuditLog.create({
|
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||||
data: createDocumentAuditLogData({
|
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
if (changes.length > 0) {
|
||||||
documentId,
|
await tx.documentAuditLog.create({
|
||||||
user,
|
data: createDocumentAuditLogData({
|
||||||
requestMetadata,
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||||
data: {
|
documentId,
|
||||||
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
user,
|
||||||
},
|
requestMetadata,
|
||||||
}),
|
data: {
|
||||||
});
|
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return upsertedDocumentMeta;
|
return upsertedDocumentMeta;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,80 +10,127 @@ import { DocumentStatus } from '@documenso/prisma/client';
|
|||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
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 = {
|
export type DeleteDocumentOptions = {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
status: DocumentStatus;
|
teamId?: number;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
|
export const deleteDocument = async ({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
requestMetadata,
|
||||||
|
}: DeleteDocumentOptions) => {
|
||||||
|
const document = await prisma.document.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
...(teamId
|
||||||
|
? {
|
||||||
|
team: {
|
||||||
|
id: teamId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
userId,
|
||||||
|
teamId: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
documentMeta: true,
|
||||||
|
User: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, User: user } = document;
|
||||||
|
|
||||||
// if the document is a draft, hard-delete
|
// if the document is a draft, hard-delete
|
||||||
if (status === DocumentStatus.DRAFT) {
|
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 the document is pending, send cancellation emails to all recipients
|
||||||
if (status === DocumentStatus.PENDING) {
|
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
await Promise.all(
|
||||||
where: {
|
document.Recipient.map(async (recipient) => {
|
||||||
id: userId,
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const document = await prisma.document.findUnique({
|
const template = createElement(DocumentCancelTemplate, {
|
||||||
where: {
|
documentName: document.title,
|
||||||
id,
|
inviterName: user.name || undefined,
|
||||||
status,
|
inviterEmail: user.email,
|
||||||
userId,
|
assetBaseUrl,
|
||||||
},
|
});
|
||||||
include: {
|
|
||||||
Recipient: true,
|
|
||||||
documentMeta: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!document) {
|
await mailer.sendMail({
|
||||||
throw new Error('Document not found');
|
to: {
|
||||||
}
|
address: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
if (document.Recipient.length > 0) {
|
},
|
||||||
await Promise.all(
|
from: {
|
||||||
document.Recipient.map(async (recipient) => {
|
name: FROM_NAME,
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
address: FROM_ADDRESS,
|
||||||
|
},
|
||||||
const template = createElement(DocumentCancelTemplate, {
|
subject: 'Document Cancelled',
|
||||||
documentName: document.title,
|
html: render(template),
|
||||||
inviterName: user.name || undefined,
|
text: render(template, { plainText: true }),
|
||||||
inviterEmail: user.email,
|
});
|
||||||
assetBaseUrl,
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
await mailer.sendMail({
|
|
||||||
to: {
|
|
||||||
address: recipient.email,
|
|
||||||
name: recipient.name,
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
name: FROM_NAME,
|
|
||||||
address: FROM_ADDRESS,
|
|
||||||
},
|
|
||||||
subject: 'Document Cancelled',
|
|
||||||
html: render(template),
|
|
||||||
text: render(template, { plainText: true }),
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the document is not a draft, only soft-delete.
|
// If the document is not a draft, only soft-delete.
|
||||||
return await prisma.document.update({
|
return await prisma.$transaction(async (tx) => {
|
||||||
where: {
|
await tx.documentAuditLog.create({
|
||||||
id,
|
data: createDocumentAuditLogData({
|
||||||
},
|
documentId: id,
|
||||||
data: {
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||||
deletedAt: new Date().toISOString(),
|
user,
|
||||||
},
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
type: 'SOFT',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await tx.document.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user