Compare commits
30 Commits
feat/check
...
feat/add-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d569292ce | ||
|
|
b1474de2da | ||
|
|
6b8ff4567d | ||
|
|
aa4b6f1723 | ||
|
|
c8a09099a3 | ||
|
|
0f87dc047b | ||
|
|
80c758fb62 | ||
|
|
1a81b46859 | ||
|
|
ef1d4ed0fa | ||
|
|
7705dbae0c | ||
|
|
8b58f10cbe | ||
|
|
fe1f0e6a76 | ||
|
|
a82975fd78 | ||
|
|
a4967f19e8 | ||
|
|
a311869c9b | ||
|
|
6f3cea52e8 | ||
|
|
732827f81d | ||
|
|
bfff1234bb | ||
|
|
93a149d637 | ||
|
|
f7ae3104ea | ||
|
|
64870f22b9 | ||
|
|
12e4bc918d | ||
|
|
e36763a85d | ||
|
|
0bc9c590a7 | ||
|
|
4d4dfd3c5f | ||
|
|
c9b4915fc8 | ||
|
|
110f9bae12 | ||
|
|
318a77c936 | ||
|
|
2bc0407d06 | ||
|
|
80de7d3ea9 |
@@ -17,7 +17,8 @@ For the digital signature of your documents you need a signing certificate in .p
|
||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||
|
||||
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
||||
5. Place the certificate `/apps/web/resources/certificate.p12`
|
||||
|
||||
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||
const config = {
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
|
||||
import type { TClaimPlanRequestSchema } from './types';
|
||||
import { ZClaimPlanResponseSchema } from './types';
|
||||
|
||||
export const claimPlan = async ({
|
||||
name,
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import launchWeekTwoImage from '@documenso/assets/images/background-lw-2.png';
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -48,16 +46,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
})}
|
||||
>
|
||||
{showProfilesAnnouncementBar && (
|
||||
<div className="relative inline-flex w-full items-center justify-center overflow-hidden px-4 py-2.5">
|
||||
<div className="absolute inset-0 -z-[1]">
|
||||
<Image
|
||||
src={launchWeekTwoImage}
|
||||
className="h-full w-full object-cover"
|
||||
alt="Launch Week 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-background text-center text-sm text-white">
|
||||
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
|
||||
<div className="text-black text-center text-sm font-medium">
|
||||
Claim your documenso public profile username now!{' '}
|
||||
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
|
||||
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">
|
||||
|
||||
@@ -55,6 +55,7 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
<Bar
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
dataKey={metricKey as string}
|
||||
maxBarSize={60}
|
||||
fill="hsl(var(--primary))"
|
||||
|
||||
@@ -13,6 +13,7 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
||||
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
|
||||
const formattedData = data.map((item) => ({
|
||||
amount: Number(item.amount),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
date: formatMonth(item.date as string),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import {
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Variants, motion } from 'framer-motion';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { TOSSFriendsSchema } from './schema';
|
||||
import type { TOSSFriendsSchema } from './schema';
|
||||
|
||||
const ContainerVariants: Variants = {
|
||||
initial: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import LogoImage from '@documenso/assets/logo.png';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
import { StatusWidgetContainer } from './status-widget-container';
|
||||
// import { StatusWidgetContainer } from './status-widget-container';
|
||||
|
||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
@@ -65,9 +65,9 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{/* <div className="mt-6">
|
||||
<StatusWidgetContainer />
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
|
||||
variants={HeroTitleVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]"
|
||||
className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]"
|
||||
>
|
||||
Document signing,
|
||||
<span className="block" /> finally open source.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget';
|
||||
export function StatusWidgetContainer() {
|
||||
return (
|
||||
<Suspense fallback={<StatusWidgetFallback />}>
|
||||
<StatusWidget />
|
||||
<StatusWidget slug="documenso-status" />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { use, useMemo } from 'react';
|
||||
import { memo, use } from 'react';
|
||||
|
||||
import type { Status } from '@openstatus/react';
|
||||
import { getStatus } from '@openstatus/react';
|
||||
import { type Status, getStatus } from '@openstatus/react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
@@ -45,9 +44,8 @@ const getStatusLevel = (level: Status) => {
|
||||
}[level];
|
||||
};
|
||||
|
||||
export function StatusWidget() {
|
||||
const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []);
|
||||
const { status } = use(getStatusMemoized);
|
||||
export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
|
||||
const { status } = use(getStatus(slug));
|
||||
const level = getStatusLevel(status);
|
||||
|
||||
return (
|
||||
@@ -72,4 +70,4 @@ export function StatusWidget() {
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
{signatureText && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||
'text-foreground truncate text-4xl font-semibold [font-family:var(--font-caveat)]',
|
||||
)}
|
||||
>
|
||||
{signatureText}
|
||||
@@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
>
|
||||
<Input
|
||||
id="signatureText"
|
||||
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0"
|
||||
className="text-foreground placeholder:text-muted-foreground truncate border-none p-0 text-sm focus-visible:ring-0"
|
||||
placeholder="Draw or type name here"
|
||||
disabled={isSubmitting}
|
||||
{...register('signatureText', {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { FieldError } from 'react-hook-form';
|
||||
import type { FieldError } from 'react-hook-form';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
import type { SVGAttributes } from 'react';
|
||||
|
||||
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import { ThemeProviderProps } from 'next-themes/dist/types';
|
||||
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
|
||||
@@ -23,7 +23,7 @@ const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
@@ -4,7 +4,16 @@ import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
ScrollTextIcon,
|
||||
Share,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
@@ -106,6 +115,13 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`${documentsPath}/${document.id}/logs`}>
|
||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||
Audit Log
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, DownloadIcon } from 'lucide-react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
@@ -10,7 +10,6 @@ import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
|
||||
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 {
|
||||
@@ -19,6 +18,8 @@ import {
|
||||
} from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentLogsDataTable } from './document-logs-data-table';
|
||||
import { DownloadAuditLogButton } from './download-audit-log-button';
|
||||
import { DownloadCertificateButton } from './download-certificate-button';
|
||||
|
||||
export type DocumentLogsPageViewProps = {
|
||||
params: {
|
||||
@@ -132,15 +133,9 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<DownloadCertificateButton className="mr-2" documentId={document.id} />
|
||||
|
||||
<Button className="w-full sm:w-auto">
|
||||
<DownloadIcon className="mr-1.5 h-4 w-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
<DownloadAuditLogButton documentId={document.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadAuditLogButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: downloadAuditLogs, isLoading } =
|
||||
trpc.document.downloadAuditLogs.useMutation();
|
||||
|
||||
const onDownloadAuditLogsClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadAuditLogs({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
loading={isLoading}
|
||||
onClick={() => void onDownloadAuditLogsClick()}
|
||||
>
|
||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||
Download Audit Logs
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadCertificateButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const DownloadCertificateButton = ({
|
||||
className,
|
||||
documentId,
|
||||
}: DownloadCertificateButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: downloadCertificate, isLoading } =
|
||||
trpc.document.downloadCertificate.useMutation();
|
||||
|
||||
const onDownloadCertificatesClick = async () => {
|
||||
try {
|
||||
const { url } = await downloadCertificate({ documentId });
|
||||
|
||||
const iframe = Object.assign(document.createElement('iframe'), {
|
||||
src: url,
|
||||
});
|
||||
|
||||
Object.assign(iframe.style, {
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
});
|
||||
|
||||
const onLoaded = () => {
|
||||
if (iframe.contentDocument?.readyState === 'complete') {
|
||||
iframe.contentWindow?.print();
|
||||
|
||||
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||
document.body.removeChild(iframe);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||
iframe.addEventListener('load', onLoaded);
|
||||
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
onLoaded();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Sorry, we were unable to download the certificate. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('w-full sm:w-auto', className)}
|
||||
loading={isLoading}
|
||||
variant="outline"
|
||||
onClick={() => void onDownloadCertificatesClick()}
|
||||
>
|
||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||
Download Certificate
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
export type AuditLogDataTableProps = {
|
||||
logs: TDocumentAuditLog[];
|
||||
};
|
||||
|
||||
const dateFormat: DateTimeFormatOptions = {
|
||||
...DateTime.DATETIME_SHORT,
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
const parser = new UAParser();
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Browser</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody className="print:text-xs">
|
||||
{logs.map((log, i) => (
|
||||
<TableRow className="break-inside-avoid" key={i}>
|
||||
<TableCell>
|
||||
<LocaleDate format={dateFormat} date={log.createdAt} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{log.name || log.email ? (
|
||||
<div>
|
||||
{log.name && (
|
||||
<p className="break-all" title={log.name}>
|
||||
{log.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{log.email && (
|
||||
<p className="text-muted-foreground break-all" title={log.email}>
|
||||
{log.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>N/A</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{log.ipAddress}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
139
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
Normal file
139
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { AuditLogDataTable } from './data-table';
|
||||
|
||||
type AuditLogProps = {
|
||||
searchParams: {
|
||||
d: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const rawDocumentId = decryptSecondaryData(d);
|
||||
|
||||
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentId = Number(rawDocumentId);
|
||||
|
||||
const document = await getEntireDocument({
|
||||
id: documentId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const { data: auditLogs } = await findDocumentAuditLogs({
|
||||
documentId: documentId,
|
||||
userId: document.userId,
|
||||
perPage: 100_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Version History</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
|
||||
<p>
|
||||
<span className="font-medium">Document ID</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.id}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Enclosed Document</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.title}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Status</span>
|
||||
|
||||
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Owner</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.User.name} ({document.User.email})
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Created At</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
<LocaleDate date={document.createdAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Last Updated</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
<LocaleDate date={document.updatedAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Time Zone</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.documentMeta?.timezone ?? 'N/A'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-medium">Recipients</p>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{document.Recipient.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
<span className="text-muted-foreground">
|
||||
[{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}]
|
||||
</span>{' '}
|
||||
{recipient.name} ({recipient.email})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-8">
|
||||
<CardContent className="p-0">
|
||||
<AuditLogDataTable logs={auditLogs} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<Logo className="max-h-6 print:max-h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
Normal file
299
apps/web/src/app/(internal)/%5F%5Fhtmltopdf/certificate/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
} from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
type SigningCertificateProps = {
|
||||
searchParams: {
|
||||
d: string;
|
||||
};
|
||||
};
|
||||
|
||||
const FRIENDLY_SIGNING_REASONS = {
|
||||
['__OWNER__']: 'I am the owner of this document',
|
||||
...RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
};
|
||||
|
||||
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const rawDocumentId = decryptSecondaryData(d);
|
||||
|
||||
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentId = Number(rawDocumentId);
|
||||
|
||||
const document = await getEntireDocument({
|
||||
id: documentId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||
id: documentId,
|
||||
});
|
||||
|
||||
const isOwner = (email: string) => {
|
||||
return email.toLowerCase() === document.User.email.toLowerCase();
|
||||
};
|
||||
|
||||
const getDevice = (userAgent?: string | null) => {
|
||||
if (!userAgent) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const parser = new UAParser(userAgent);
|
||||
|
||||
parser.setUA(userAgent);
|
||||
|
||||
const result = parser.getResult();
|
||||
|
||||
return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
|
||||
};
|
||||
|
||||
const getAuthenticationLevel = (recipientId: number) => {
|
||||
const recipient = document.Recipient.find((recipient) => recipient.id === recipientId);
|
||||
|
||||
if (!recipient) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const extractedAuthMethods = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
|
||||
.with('ACCOUNT', () => 'Account Re-Authentication')
|
||||
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
|
||||
.with('PASSKEY', () => 'Passkey Re-Authentication')
|
||||
.with('EXPLICIT_NONE', () => 'Email')
|
||||
.with(null, () => null)
|
||||
.exhaustive();
|
||||
|
||||
if (!authLevel) {
|
||||
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
|
||||
.with('ACCOUNT', () => 'Account Authentication')
|
||||
.with(null, () => 'Email')
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
return authLevel;
|
||||
};
|
||||
|
||||
const getRecipientAuditLogs = (recipientId: number) => {
|
||||
return {
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
|
||||
].filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED &&
|
||||
log.data.recipientId === recipientId,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED
|
||||
].filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
|
||||
log.data.recipientId === recipientId,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getRecipientSignatureField = (recipientId: number) => {
|
||||
return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find(
|
||||
(field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Signer Events</TableHead>
|
||||
<TableHead>Signature</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
{/* <TableHead>Security</TableHead> */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody className="print:text-xs">
|
||||
{document.Recipient.map((recipient, i) => {
|
||||
const logs = getRecipientAuditLogs(recipient.id);
|
||||
const signature = getRecipientSignatureField(recipient.id);
|
||||
|
||||
return (
|
||||
<TableRow key={i} className="print:break-inside-avoid">
|
||||
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
|
||||
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
|
||||
<div className="break-all">{recipient.email}</div>
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Authentication Level:</span>{' '}
|
||||
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
|
||||
</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
{signature ? (
|
||||
<>
|
||||
<div
|
||||
className="inline-block rounded-lg p-1"
|
||||
style={{
|
||||
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${signature.Signature?.signatureImageAsBase64}`}
|
||||
alt="Signature"
|
||||
className="max-h-12 max-w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Signature ID:</span>{' '}
|
||||
<span className="block font-mono uppercase">
|
||||
{signature.secondaryId}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">IP Address:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
||||
<span className="font-medium">Device:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">N/A</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Sent:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.EMAIL_SENT[0] ? (
|
||||
<LocaleDate
|
||||
date={logs.EMAIL_SENT[0].createdAt}
|
||||
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||
/>
|
||||
) : (
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Viewed:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_OPENED[0] ? (
|
||||
<LocaleDate
|
||||
date={logs.DOCUMENT_OPENED[0].createdAt}
|
||||
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||
/>
|
||||
) : (
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Signed:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? (
|
||||
<LocaleDate
|
||||
date={logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt}
|
||||
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
|
||||
/>
|
||||
) : (
|
||||
'Unknown'
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Reason:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{isOwner(recipient.email)
|
||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||
: FRIENDLY_SIGNING_REASONS[recipient.role]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||
Signing certificate provided by:
|
||||
</p>
|
||||
|
||||
<Logo className="max-h-6 print:max-h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type ClaimAccountProps = {
|
||||
defaultName: string;
|
||||
defaultEmail: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ZClaimAccountFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
email: z.string().email().min(1),
|
||||
password: ZPasswordSchema,
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const { name, email, password } = data;
|
||||
return !password.includes(name) && !password.includes(email.split('@')[0]);
|
||||
},
|
||||
{
|
||||
message: 'Password should not be common or based on personal information',
|
||||
path: ['password'],
|
||||
},
|
||||
);
|
||||
|
||||
export type TClaimAccountFormSchema = z.infer<typeof ZClaimAccountFormSchema>;
|
||||
|
||||
export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => {
|
||||
const analytics = useAnalytics();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
|
||||
|
||||
const form = useForm<TClaimAccountFormSchema>({
|
||||
values: {
|
||||
name: defaultName ?? '',
|
||||
email: defaultEmail,
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(ZClaimAccountFormSchema),
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
|
||||
try {
|
||||
await signup({ name, email, password });
|
||||
|
||||
router.push(`/unverified-account`);
|
||||
|
||||
toast({
|
||||
title: 'Registration Successful',
|
||||
description:
|
||||
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: User Claim Account', {
|
||||
email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} 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',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to sign you up. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="mt-4">
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter your name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Email address</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter your email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Set a password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="Pick a password" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
|
||||
Claim account
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { CheckCircle2, Clock8 } from 'lucide-react';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { env } from 'next-runtime-env';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
@@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { SigningAuthPageView } from '../signing-auth-page';
|
||||
import { ClaimAccount } from './claim-account';
|
||||
import { DocumentPreviewButton } from './document-preview-button';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
@@ -31,6 +35,8 @@ export type CompletedSigningPageProps = {
|
||||
export default async function CompletedSigningPage({
|
||||
params: { token },
|
||||
}: CompletedSigningPageProps) {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
if (!token) {
|
||||
return notFound();
|
||||
}
|
||||
@@ -79,96 +85,120 @@ export default async function CompletedSigningPage({
|
||||
|
||||
const sessionData = await getServerSession();
|
||||
const isLoggedIn = !!sessionData?.user;
|
||||
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
|
||||
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
|
||||
canSignUp,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('flex flex-col items-center', {
|
||||
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
|
||||
})}
|
||||
>
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
|
||||
<div className="relative mt-6 flex w-full flex-col items-center">
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<div className="text-documenso-700 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Everyone has signed</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Waiting for others to sign</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Document no longer available to sign</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
You have
|
||||
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
||||
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
||||
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||
</h2>
|
||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
Document
|
||||
{recipient.role === RecipientRole.SIGNER && ' Signed '}
|
||||
{recipient.role === RecipientRole.VIEWER && ' Viewed '}
|
||||
{recipient.role === RecipientRole.APPROVER && ' Approved '}
|
||||
</h2>
|
||||
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
Everyone has signed! You will receive an Email copy of the signed document.
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
You will receive an Email copy of the signed document once everyone has signed.
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
This document has been cancelled by the owner and is no longer available for others to
|
||||
sign.
|
||||
</p>
|
||||
))}
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<div className="text-documenso-700 mt-4 flex items-center text-center">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Everyone has signed</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Waiting for others to sign</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">Document no longer available to sign</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
.with({ status: DocumentStatus.COMPLETED }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
Everyone has signed! You will receive an Email copy of the signed document.
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
You will receive an Email copy of the signed document once everyone has signed.
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
|
||||
This document has been cancelled by the owner and is no longer available for others
|
||||
to sign.
|
||||
</p>
|
||||
))}
|
||||
|
||||
{document.status === DocumentStatus.COMPLETED ? (
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
|
||||
{document.status === DocumentStatus.COMPLETED ? (
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn ? (
|
||||
{canSignUp && (
|
||||
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
|
||||
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
|
||||
Need to sign documents?
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
|
||||
Create your account and start using state-of-the-art document signing.
|
||||
</p>
|
||||
|
||||
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn && (
|
||||
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
|
||||
Go Back Home
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 mt-36 text-sm">
|
||||
Want to send slick signing links like this one?{' '}
|
||||
<Link
|
||||
href="https://documenso.com"
|
||||
className="text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
Check out Documenso.
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
CommandDialog,
|
||||
@@ -71,7 +69,6 @@ export type CommandMenuProps = {
|
||||
|
||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const { setTheme } = useTheme();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -93,17 +90,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const isOwner = useCallback(
|
||||
(document: Document) => document.userId === session?.user.id,
|
||||
[session?.user.id],
|
||||
);
|
||||
|
||||
const getSigningLink = useCallback(
|
||||
(recipients: Recipient[]) =>
|
||||
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
|
||||
[session?.user.email],
|
||||
);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchDocumentsData) {
|
||||
return [];
|
||||
@@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
|
||||
return searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
|
||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
path: document.path,
|
||||
value: document.value,
|
||||
}));
|
||||
}, [searchDocumentsData, isOwner, getSigningLink]);
|
||||
}, [searchDocumentsData]);
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
<Button
|
||||
data-testid="menu-switcher"
|
||||
variant="none"
|
||||
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent"
|
||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||
@@ -102,12 +102,13 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
rightSideComponent={
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||
}
|
||||
textSectionClassName="hidden lg:flex"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
|
||||
@@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
||||
<SheetContent className="flex w-full max-w-[350px] flex-col">
|
||||
<Link href="/" onClick={handleMenuItemClick}>
|
||||
<Image
|
||||
src={LogoImage}
|
||||
@@ -87,7 +87,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
@@ -4701,6 +4702,19 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/browser-chromium": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz",
|
||||
"integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.43.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
|
||||
@@ -4716,6 +4730,50 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.40.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test/node_modules/playwright-core": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz",
|
||||
@@ -17615,12 +17673,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
||||
"dev": true,
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
|
||||
"integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.40.0"
|
||||
"playwright-core": "1.43.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -17633,10 +17690,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
||||
"dev": true,
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
|
||||
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
@@ -17648,7 +17704,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -24926,6 +24981,7 @@
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@@ -24933,6 +24989,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "^1.43.0",
|
||||
"@types/luxon": "^3.3.1"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "^1.43.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
@@ -59,4 +60,4 @@
|
||||
"next": "14.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,34 @@ 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.',
|
||||
export const OpenAPIV1 = Object.assign(
|
||||
generateOpenApi(
|
||||
ApiContractV1,
|
||||
{
|
||||
info: {
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
setOperationId: true,
|
||||
},
|
||||
),
|
||||
{
|
||||
setOperationId: true,
|
||||
components: {
|
||||
securitySchemes: {
|
||||
authorization: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{
|
||||
authorization: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -254,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
await expect(page.getByText('You have signed')).toBeVisible();
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
|
||||
@@ -32,3 +32,10 @@ export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
|
||||
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
|
||||
} as const;
|
||||
|
||||
export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||
[RecipientRole.SIGNER]: 'I am a signer of this document',
|
||||
[RecipientRole.APPROVER]: 'I am an approver of this document',
|
||||
[RecipientRole.CC]: 'I am required to recieve a copy of this document',
|
||||
[RecipientRole.VIEWER]: 'I am a viewer of this document',
|
||||
} satisfies Record<keyof typeof RecipientRole, string>;
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"next-auth": "4.24.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.43.0",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
@@ -46,6 +47,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.3.1"
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@playwright/browser-chromium": "^1.43.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,14 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
Recipient: {
|
||||
include: {
|
||||
Field: {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/document-audit-logs';
|
||||
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type GetDocumentCertificateAuditLogsOptions = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export const getDocumentCertificateAuditLogs = async ({
|
||||
id,
|
||||
}: GetDocumentCertificateAuditLogsOptions) => {
|
||||
const rawAuditLogs = await prisma.documentAuditLog.findMany({
|
||||
where: {
|
||||
documentId: id,
|
||||
type: {
|
||||
in: [
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const auditLogs = rawAuditLogs.map((log) => parseDocumentAuditLogData(log));
|
||||
|
||||
const groupedAuditLogs = {
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&
|
||||
log.data.emailType !== DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED,
|
||||
),
|
||||
} as const;
|
||||
|
||||
return groupedAuditLogs;
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import { signPdf } from '@documenso/signing';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
|
||||
@@ -91,6 +92,10 @@ export const sealDocument = async ({
|
||||
// !: Need to write the fields onto the document as a hard copy
|
||||
const pdfData = await getFile(documentData);
|
||||
|
||||
const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
|
||||
PDFDocument.load(doc),
|
||||
);
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
@@ -98,6 +103,12 @@ export const sealDocument = async ({
|
||||
doc.getForm().flatten();
|
||||
flattenAnnotations(doc);
|
||||
|
||||
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
|
||||
|
||||
certificatePages.forEach((page) => {
|
||||
doc.addPage(page);
|
||||
});
|
||||
|
||||
for (const field of fields) {
|
||||
await insertFieldInPDF(doc, field);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
|
||||
export type SearchDocumentsWithKeywordOptions = {
|
||||
query: string;
|
||||
@@ -79,12 +78,19 @@ export const searchDocumentsWithKeyword = async ({
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const maskedDocuments = documents.map((document) =>
|
||||
maskRecipientTokensForDocument({
|
||||
document,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
const isOwner = (document: Document, user: User) => document.userId === user.id;
|
||||
const getSigningLink = (recipients: Recipient[], user: User) =>
|
||||
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
|
||||
|
||||
const maskedDocuments = documents.map((document) => {
|
||||
const { Recipient, ...documentWithoutRecipient } = document;
|
||||
|
||||
return {
|
||||
...documentWithoutRecipient,
|
||||
path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user),
|
||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
};
|
||||
});
|
||||
|
||||
return maskedDocuments;
|
||||
};
|
||||
|
||||
45
packages/lib/server-only/htmltopdf/get-certificate-pdf.ts
Normal file
45
packages/lib/server-only/htmltopdf/get-certificate-pdf.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import type { Browser } from 'playwright';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||
|
||||
export type GetCertificatePdfOptions = {
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions) => {
|
||||
const encryptedId = encryptSecondaryData({
|
||||
data: documentId.toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
});
|
||||
|
||||
let browser: Browser;
|
||||
|
||||
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
|
||||
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
|
||||
} else {
|
||||
browser = await chromium.launch();
|
||||
}
|
||||
|
||||
if (!browser) {
|
||||
throw new Error(
|
||||
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
|
||||
);
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
const result = await page.pdf({
|
||||
format: 'A4',
|
||||
});
|
||||
|
||||
void browser.close();
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -7,6 +7,9 @@ module.exports = {
|
||||
content: ['src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
print: { raw: 'print' },
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['var(--font-sans)', ...fontFamily.sans],
|
||||
signature: ['var(--font-signature)'],
|
||||
|
||||
@@ -56,7 +56,7 @@ export const authRouter = router({
|
||||
|
||||
return user;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
console.error(err);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ZSignUpMutationSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().min(1, { message: 'A signature is required.' }),
|
||||
signature: z.string().nullish(),
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||
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';
|
||||
@@ -22,6 +25,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateDocumentMutationSchema,
|
||||
ZDeleteDraftDocumentMutationSchema as ZDeleteDocumentMutationSchema,
|
||||
ZDownloadAuditLogsMutationSchema,
|
||||
ZFindDocumentAuditLogsQuerySchema,
|
||||
ZGetDocumentByIdQuerySchema,
|
||||
ZGetDocumentByTokenQuerySchema,
|
||||
@@ -354,6 +358,7 @@ export const documentRouter = router({
|
||||
query,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
|
||||
return documents;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -364,4 +369,66 @@ export const documentRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
downloadAuditLogs: authenticatedProcedure
|
||||
.input(ZDownloadAuditLogsMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId, teamId } = input;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const encrypted = encryptSecondaryData({
|
||||
data: document.id.toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'We were unable to download the audit logs for this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
downloadCertificate: authenticatedProcedure
|
||||
.input(ZDownloadAuditLogsMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId, teamId } = input;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const encrypted = encryptSecondaryData({
|
||||
data: document.id.toString(),
|
||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'We were unable to download the audit logs for this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -163,3 +163,8 @@ export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocu
|
||||
export const ZSearchDocumentsMutationSchema = z.object({
|
||||
query: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadAuditLogsMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
3
packages/tsconfig/process-env.d.ts
vendored
3
packages/tsconfig/process-env.d.ts
vendored
@@ -61,6 +61,9 @@ declare namespace NodeJS {
|
||||
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP?: string;
|
||||
|
||||
//
|
||||
NEXT_PRIVATE_BROWSERLESS_URL?: string;
|
||||
|
||||
/**
|
||||
* Vercel environment variables
|
||||
*/
|
||||
|
||||
@@ -55,6 +55,8 @@ type AvatarWithTextProps = {
|
||||
primaryText: React.ReactNode;
|
||||
secondaryText?: React.ReactNode;
|
||||
rightSideComponent?: React.ReactNode;
|
||||
// Optional class to hide/show the text beside avatar
|
||||
textSectionClassName?: string;
|
||||
};
|
||||
|
||||
const AvatarWithText = ({
|
||||
@@ -64,6 +66,7 @@ const AvatarWithText = ({
|
||||
primaryText,
|
||||
secondaryText,
|
||||
rightSideComponent,
|
||||
textSectionClassName,
|
||||
}: AvatarWithTextProps) => (
|
||||
<div className={cn('flex w-full max-w-xs items-center gap-2', className)}>
|
||||
<Avatar
|
||||
@@ -72,7 +75,7 @@ const AvatarWithText = ({
|
||||
<AvatarFallback className="text-xs text-gray-400">{avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col text-left text-sm font-normal">
|
||||
<div className={cn('flex flex-col text-left text-sm font-normal', textSectionClassName)}>
|
||||
<span className="text-foreground truncate">{primaryText}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">{secondaryText}</span>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,11 @@ type CommandDialogProps = DialogProps & {
|
||||
const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
||||
<DialogContent
|
||||
className="w-11/12 items-center overflow-hidden rounded-lg p-0 shadow-2xl lg:mt-0"
|
||||
position="center"
|
||||
overlayClassName="bg-background/60"
|
||||
>
|
||||
<Command
|
||||
{...commandProps}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-0 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
|
||||
|
||||
@@ -54,28 +54,35 @@ const DialogContent = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
position?: 'start' | 'end' | 'center';
|
||||
hideClose?: boolean;
|
||||
/* Below prop is to add additional classes to the overlay */
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, position = 'start', hideClose = false, ...props }, ref) => (
|
||||
<DialogPortal position={position}>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{ className, children, overlayClassName, position = 'start', hideClose = false, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<DialogPortal position={position}>
|
||||
<DialogOverlay className={cn(overlayClassName)} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background animate-in data-[state=open]:fade-in-90 sm:zoom-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border p-6 shadow-lg sm:max-w-lg sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
),
|
||||
);
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, { useId, useMemo, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { motion } from 'framer-motion';
|
||||
import { InfoIcon, Plus, Trash } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
@@ -60,6 +61,8 @@ export const AddSignersFormPartial = ({
|
||||
}: AddSignersFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
|
||||
const initialId = useId();
|
||||
|
||||
@@ -135,6 +138,18 @@ export const AddSignersFormPartial = ({
|
||||
);
|
||||
};
|
||||
|
||||
const onAddSelfSigner = () => {
|
||||
const newSelfSignerId = nanoid(12);
|
||||
|
||||
appendSigner({
|
||||
formId: newSelfSignerId,
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
@@ -209,8 +224,12 @@ export const AddSignersFormPartial = ({
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
{...field}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -237,8 +256,12 @@ export const AddSignersFormPartial = ({
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
{...field}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -403,32 +426,46 @@ export const AddSignersFormPartial = ({
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting || signers.length >= remaining.recipients}
|
||||
onClick={() => onAddSigner()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Signer
|
||||
</Button>
|
||||
|
||||
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="showAdvancedRecipientSettings"
|
||||
className="h-5 w-5"
|
||||
checkClassName="dark:text-white text-primary"
|
||||
checked={showAdvancedSettings}
|
||||
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
htmlFor="showAdvancedRecipientSettings"
|
||||
>
|
||||
Show advanced settings
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
form.getValues('signers').some((signer) => signer.email === user?.email)
|
||||
}
|
||||
onClick={() => onAddSelfSigner()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add myself
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!alwaysShowAdvancedSettings && isDocumentEnterprise && (
|
||||
<div className="mt-4 flex flex-row items-center">
|
||||
<Checkbox
|
||||
id="showAdvancedRecipientSettings"
|
||||
className="h-5 w-5"
|
||||
checkClassName="dark:text-white text-primary"
|
||||
checked={showAdvancedSettings}
|
||||
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
htmlFor="showAdvancedRecipientSettings"
|
||||
>
|
||||
Show advanced settings
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</AnimateGenericFadeInOut>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
@@ -2,13 +2,16 @@ import * as React from 'react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement> & {
|
||||
overflowHidden?: boolean;
|
||||
}
|
||||
>(({ className, overflowHidden, ...props }, ref) => (
|
||||
<div className={cn('w-full', overflowHidden ? 'overflow-hidden' : 'overflow-auto')}>
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
));
|
||||
|
||||
Table.displayName = 'Table';
|
||||
|
||||
@@ -76,11 +79,17 @@ TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.TdHTMLAttributes<HTMLTableCellElement> & {
|
||||
truncate?: boolean;
|
||||
}
|
||||
>(({ className, truncate = true, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('truncate p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
className={cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||
truncate && 'truncate',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, { useId, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Plus, Trash } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
@@ -41,6 +42,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
onSubmit,
|
||||
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
||||
const initialId = useId();
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
|
||||
recipients.length > 1 ? recipients.length + 1 : 2,
|
||||
);
|
||||
@@ -50,6 +53,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
|
||||
resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema),
|
||||
@@ -85,6 +89,17 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
name: 'signers',
|
||||
});
|
||||
|
||||
const onAddPlaceholderSelfRecipient = () => {
|
||||
const newSelfSignerId = nanoid(12);
|
||||
|
||||
appendSigner({
|
||||
formId: newSelfSignerId,
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddPlaceholderRecipient = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
@@ -203,11 +218,27 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
error={'signers__root' in errors && errors['signers__root']}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="button" disabled={isSubmitting} onClick={() => onAddPlaceholderRecipient()}>
|
||||
<div className="mt-4 flex flex-row items-center space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => onAddPlaceholderRecipient()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Placeholder Recipient
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
||||
disabled={
|
||||
isSubmitting || getValues('signers').some((signer) => signer.email === user?.email)
|
||||
}
|
||||
onClick={() => onAddPlaceholderSelfRecipient()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Myself
|
||||
</Button>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
|
||||
@@ -97,6 +97,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom CSS for printing reports
|
||||
* - Sets page margins to 0.5 inches
|
||||
* - Hides the header and footer
|
||||
* - Hides the print button
|
||||
* - Sets page size to A4
|
||||
* - Sets the font size to 12pt
|
||||
*/
|
||||
.print-provider {
|
||||
@page {
|
||||
margin: 1in;
|
||||
size: A4;
|
||||
}
|
||||
}
|
||||
|
||||
.gradient-border-mask::before {
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
|
||||
24
turbo.json
24
turbo.json
@@ -2,8 +2,13 @@
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"cache": false
|
||||
@@ -19,7 +24,9 @@
|
||||
"persistent": true
|
||||
},
|
||||
"start": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
@@ -27,11 +34,15 @@
|
||||
"cache": false
|
||||
},
|
||||
"test:e2e": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"cache": false
|
||||
}
|
||||
},
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"globalDependencies": [
|
||||
"**/.env.*local"
|
||||
],
|
||||
"globalEnv": [
|
||||
"APP_VERSION",
|
||||
"NEXT_PRIVATE_ENCRYPTION_KEY",
|
||||
@@ -93,6 +104,7 @@
|
||||
"NEXT_PRIVATE_STRIPE_API_KEY",
|
||||
"NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET",
|
||||
"NEXT_PRIVATE_GITHUB_TOKEN",
|
||||
"NEXT_PRIVATE_BROWSERLESS_URL",
|
||||
"CI",
|
||||
"VERCEL",
|
||||
"VERCEL_ENV",
|
||||
@@ -110,4 +122,4 @@
|
||||
"E2E_TEST_AUTHENTICATE_USER_EMAIL",
|
||||
"E2E_TEST_AUTHENTICATE_USER_PASSWORD"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user