mirror of
https://github.com/documenso/documenso.git
synced 2026-07-12 05:55:12 +10:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e9c7f1b11 | |||
| bc2ec9a2d7 | |||
| 763b7f82c9 | |||
| c670f64b1f | |||
| 369e16afab | |||
| 4a5f565591 | |||
| f544eae2a6 | |||
| a2ffd75c17 | |||
| 8619eec67a | |||
| f325a04cb5 | |||
| 2ff330f9d4 | |||
| 6a47b3a6e5 | |||
| a7adb77e47 | |||
| bfcbaea3a9 | |||
| 64964f420a | |||
| 2896673a23 | |||
| b684b9574d | |||
| 12803d1a5e | |||
| c41002313a | |||
| ce1c93b2a6 | |||
| 82337e4e3a | |||
| 516435fa2a | |||
| 7d9a3f9776 | |||
| 0216af4ae8 | |||
| cbad065dac | |||
| 25a3861c91 | |||
| b9ae277041 | |||
| 7fad826d06 | |||
| eb8ba2036a | |||
| 339759166c | |||
| 637e06f9c0 | |||
| 332e0657e0 | |||
| 4017b250fb | |||
| 41373a7c6f | |||
| 7cc85ca6bc | |||
| bc19fa0cbd | |||
| a60f58e20b | |||
| aca902b5ff | |||
| 3cde3cb7b2 | |||
| 071f5c546d | |||
| 9f9f6701c8 | |||
| b01eaceeb8 |
@@ -111,6 +111,83 @@ The colors will be automatically converted to the appropriate format internally.
|
||||
|
||||
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
||||
|
||||
## CSS Class Targets
|
||||
|
||||
In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling:
|
||||
|
||||
### Component Classes
|
||||
|
||||
| Class Name | Description |
|
||||
| --------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `.embed--Root` | Main container for the embedded signing experience |
|
||||
| `.embed--DocumentContainer` | Container for the document and signing widget |
|
||||
| `.embed--DocumentViewer` | Container for the document viewer |
|
||||
| `.embed--DocumentWidget` | The signing widget container |
|
||||
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning |
|
||||
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
|
||||
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
|
||||
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
|
||||
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
|
||||
| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign |
|
||||
| `.embed--DocumentCompleted` | Container for the completion screen after signing |
|
||||
| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) |
|
||||
|
||||
Field components also expose several data attributes that can be used for styling different states:
|
||||
|
||||
| Data Attribute | Values | Description |
|
||||
| ------------------- | ---------------------------------------------- | ------------------------------------ |
|
||||
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
|
||||
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
|
||||
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
|
||||
|
||||
### Field Styling Example
|
||||
|
||||
```css
|
||||
/* Style all field containers */
|
||||
.field--FieldRootContainer {
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
/* Style specific field types */
|
||||
.field--FieldRootContainer[data-field-type='SIGNATURE'] {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Style inserted fields */
|
||||
.field--FieldRootContainer[data-inserted='true'] {
|
||||
background-color: var(--primary);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* Style fields being validated */
|
||||
.field--FieldRootContainer[data-validate='true'] {
|
||||
border-color: orange;
|
||||
}
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
|
||||
```css
|
||||
/* Custom styles for the document widget */
|
||||
.embed--DocumentWidget {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Custom styles for the waiting screen */
|
||||
.embed--WaitingForTurn {
|
||||
background-color: #f9fafb;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for the document container */
|
||||
@media (min-width: 768px) {
|
||||
.embed--DocumentContainer {
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [React Integration](/developers/embedding/react)
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Public API
|
||||
description: Learn how to interact with your documents programmatically using the Documenso public API.
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Public API
|
||||
|
||||
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
|
||||
@@ -13,10 +15,24 @@ Documenso provides a public REST API enabling you to interact with your document
|
||||
|
||||
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
|
||||
|
||||
## Swagger Documentation
|
||||
## API V1 - Stable
|
||||
|
||||
The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
|
||||
## API V2 - Beta
|
||||
|
||||
Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more.
|
||||
|
||||
<Callout type="warning">
|
||||
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
|
||||
</Callout>
|
||||
|
||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||
|
||||
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
||||
|
||||
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
||||
|
||||
## Availability
|
||||
|
||||
The API is available to individual users and teams.
|
||||
The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
|
||||
import type { GetMonthlyActiveUsersResult } from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
|
||||
export type MonthlyActiveUsersChartProps = {
|
||||
className?: string;
|
||||
title: string;
|
||||
cummulative?: boolean;
|
||||
data: GetMonthlyActiveUsersResult;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps<ValueType, NameType>) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
||||
<p>{label}</p>
|
||||
<p className="text-documenso">
|
||||
{payload[0].name === 'cume_count' ? 'Cumulative MAU' : 'Monthly Active Users'}:{' '}
|
||||
<span className="text-black">{Number(payload[0].value).toLocaleString('en-US')}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const MonthlyActiveUsersChart = ({
|
||||
className,
|
||||
data,
|
||||
title,
|
||||
cummulative = false,
|
||||
}: MonthlyActiveUsersChartProps) => {
|
||||
const formattedData = [...data].reverse().map(({ month, count, cume_count }) => {
|
||||
return {
|
||||
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'),
|
||||
count: Number(count),
|
||||
cume_count: Number(cume_count),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
|
||||
<div className="mb-6 flex px-4">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={formattedData}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} />
|
||||
|
||||
<Bar
|
||||
dataKey={cummulative ? 'cume_count' : 'count'}
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label={cummulative ? 'Cumulative MAU' : 'Monthly Active Users'}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,6 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||
import {
|
||||
getMonthlyActiveUsers,
|
||||
getUserWithSignedDocumentMonthlyGrowth,
|
||||
getUsersCount,
|
||||
getUsersWithSubscriptionsCount,
|
||||
@@ -27,7 +26,6 @@ import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-
|
||||
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
|
||||
import { MonthlyActiveUsersChart } from './monthly-active-users-chart';
|
||||
import { SignerConversionChart } from './signer-conversion-chart';
|
||||
import { UserWithDocumentChart } from './user-with-document';
|
||||
|
||||
@@ -45,7 +43,6 @@ export default async function AdminStatsPage() {
|
||||
// userWithAtLeastOneDocumentPerMonth,
|
||||
// userWithAtLeastOneDocumentSignedPerMonth,
|
||||
MONTHLY_USERS_SIGNED,
|
||||
MONTHLY_ACTIVE_USERS,
|
||||
] = await Promise.all([
|
||||
getUsersCount(),
|
||||
getUsersWithSubscriptionsCount(),
|
||||
@@ -55,7 +52,6 @@ export default async function AdminStatsPage() {
|
||||
// getUserWithAtLeastOneDocumentPerMonth(),
|
||||
// getUserWithAtLeastOneDocumentSignedPerMonth(),
|
||||
getUserWithSignedDocumentMonthlyGrowth(),
|
||||
getMonthlyActiveUsers(),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -72,6 +68,7 @@ export default async function AdminStatsPage() {
|
||||
title={_(msg`Active Subscriptions`)}
|
||||
value={usersWithSubscriptionsCount}
|
||||
/>
|
||||
|
||||
<CardMetric
|
||||
icon={FileCog}
|
||||
title={_(msg`App Version`)}
|
||||
@@ -135,14 +132,6 @@ export default async function AdminStatsPage() {
|
||||
<Trans>Charts</Trans>
|
||||
</h3>
|
||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||
<MonthlyActiveUsersChart title={_(msg`MAU (signed in)`)} data={MONTHLY_ACTIVE_USERS} />
|
||||
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Cumulative MAU (signed in)`)}
|
||||
data={MONTHLY_ACTIVE_USERS}
|
||||
cummulative
|
||||
/>
|
||||
|
||||
<UserWithDocumentChart
|
||||
data={MONTHLY_USERS_SIGNED}
|
||||
title={_(msg`MAU (created document)`)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
|
||||
import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
@@ -23,7 +23,7 @@ const CustomTooltip = ({
|
||||
}: TooltipProps<ValueType, NameType> & { tooltip?: string }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
||||
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
||||
<p className="">{label}</p>
|
||||
<p className="text-documenso">
|
||||
{`${tooltip} : `}
|
||||
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
MailOpenIcon,
|
||||
PenIcon,
|
||||
PlusIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@@ -120,6 +121,12 @@ export const DocumentPageViewRecipients = ({
|
||||
<Trans>Viewed</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<>
|
||||
<UserIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Assisted</Trans>
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -162,7 +162,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* We don't want to allow teams moving documents across at the moment. */}
|
||||
{!team && (
|
||||
{!team && !row.teamId && (
|
||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Team</Trans>
|
||||
|
||||
@@ -40,7 +40,7 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
|
||||
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
|
||||
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -81,7 +81,7 @@ export const DataTableActionDropdown = ({
|
||||
<Trans>Direct link</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!teamId && (
|
||||
{!teamId && !row.teamId && (
|
||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Team</Trans>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
|
||||
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
|
||||
@@ -77,7 +77,11 @@ export const TemplateDirectLinkDialog = ({
|
||||
);
|
||||
|
||||
const validDirectTemplateRecipients = useMemo(
|
||||
() => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC),
|
||||
() =>
|
||||
template.recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||
),
|
||||
[template.recipients],
|
||||
);
|
||||
|
||||
|
||||
@@ -47,50 +47,22 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
// Display exactly which rows are duplicates.
|
||||
.superRefine((items, ctx) => {
|
||||
const uniqueEmails = new Map<string, number>();
|
||||
|
||||
for (const [index, recipients] of items.recipients.entries()) {
|
||||
const email = recipients.email.toLowerCase();
|
||||
|
||||
const firstFoundIndex = uniqueEmails.get(email);
|
||||
|
||||
if (firstFoundIndex === undefined) {
|
||||
uniqueEmails.set(email, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['recipients', index, 'email'],
|
||||
});
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Emails must be unique',
|
||||
path: ['recipients', firstFoundIndex, 'email'],
|
||||
});
|
||||
}
|
||||
});
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.any()
|
||||
.refine((data) => data instanceof File || data === undefined)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import { NameField } from '~/app/(signing)/sign/[token]/name-field';
|
||||
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field';
|
||||
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
|
||||
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
|
||||
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
|
||||
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
|
||||
@@ -169,7 +170,7 @@ export const SignDirectTemplateForm = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RecipientProvider recipient={directRecipient}>
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
@@ -186,16 +187,15 @@ export const SignDirectTemplateForm = ({
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
<InitialsField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -204,7 +204,6 @@ export const SignDirectTemplateForm = ({
|
||||
<NameField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -213,7 +212,6 @@ export const SignDirectTemplateForm = ({
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
onSignField={onSignField}
|
||||
@@ -224,7 +222,6 @@ export const SignDirectTemplateForm = ({
|
||||
<EmailField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -241,7 +238,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -259,7 +255,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -277,7 +272,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -295,7 +289,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -313,7 +306,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -351,6 +343,7 @@ export const SignDirectTemplateForm = ({
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -383,6 +376,6 @@ export const SignDirectTemplateForm = ({
|
||||
/>
|
||||
</div>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
</RecipientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
|
||||
type ConfirmationDialogProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
hasUninsertedFields: boolean;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export function AssistantConfirmationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
hasUninsertedFields,
|
||||
isSubmitting,
|
||||
}: ConfirmationDialogProps) {
|
||||
const onOpenChange = () => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Complete Document</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Are you sure you want to complete the document? This action cannot be undone. Please
|
||||
ensure that you have completed prefilling all relevant fields before proceeding.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SigningDisclosure />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { fromCheckboxValue, toCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -27,23 +26,19 @@ import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type CheckboxFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const CheckboxField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: CheckboxFieldProps) => {
|
||||
export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -122,7 +117,9 @@ export const CheckboxField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -151,7 +148,7 @@ export const CheckboxField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -183,28 +180,25 @@ export const CheckboxField = ({
|
||||
...checkedValues,
|
||||
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
];
|
||||
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
|
||||
if (isLengthConditionMet) {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: toCheckboxValue(checkedValues),
|
||||
isBase64: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updatedValues = checkedValues.filter(
|
||||
(v) => v !== item.value && v !== `empty-value-${item.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
await removeSignedFieldWithToken({
|
||||
setCheckedValues(updatedValues);
|
||||
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
|
||||
if (updatedValues.length > 0) {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: toCheckboxValue(updatedValues),
|
||||
isBase64: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -216,7 +210,6 @@ export const CheckboxField = ({
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCheckedValues(updatedValues);
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZDateFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -27,11 +26,11 @@ import type {
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type DateFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
dateFormat?: string | null;
|
||||
timezone?: string | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
@@ -40,17 +39,17 @@ export type DateFieldProps = {
|
||||
|
||||
export const DateField = ({
|
||||
field,
|
||||
recipient,
|
||||
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: DateFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
|
||||
@@ -67,9 +66,7 @@ export const DateField = ({
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
|
||||
|
||||
const isDifferentTime = field.inserted && localDateString !== field.customText;
|
||||
|
||||
const tooltipText = _(
|
||||
msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`,
|
||||
);
|
||||
@@ -102,7 +99,9 @@ export const DateField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -128,7 +127,7 @@ export const DateField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -30,23 +29,19 @@ import {
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type DropdownFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const DropdownField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: DropdownFieldProps) => {
|
||||
export const DropdownField = ({ field, onSignField, onUnsignField }: DropdownFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -103,7 +98,9 @@ export const DropdownField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -134,7 +131,7 @@ export const DropdownField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -23,22 +22,23 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type EmailFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => {
|
||||
export const EmailField = ({ field, onSignField, onUnsignField }: EmailFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { email: providedEmail } = useRequiredSigningContext();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@@ -86,7 +86,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -112,7 +114,7 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -21,8 +24,11 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AssistantConfirmationDialog } from './assistant/assistant-confirmation-dialog';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SignDialog } from './sign-dialog';
|
||||
|
||||
@@ -32,6 +38,8 @@ export type SigningFormProps = {
|
||||
fields: Field[];
|
||||
redirectUrl?: string | null;
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
setSelectedSignerId?: (id: number | null) => void;
|
||||
};
|
||||
|
||||
export const SigningForm = ({
|
||||
@@ -40,19 +48,35 @@ export const SigningForm = ({
|
||||
fields,
|
||||
redirectUrl,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
setSelectedSignerId,
|
||||
}: SigningFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
|
||||
useRequiredSigningContext();
|
||||
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||
defaultValues: {
|
||||
selectedSignerId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, formState } = useForm();
|
||||
|
||||
// Keep the loading state going if successful since the redirect may take some time.
|
||||
@@ -67,7 +91,11 @@ export const SigningForm = ({
|
||||
|
||||
const uninsertedFields = useMemo(() => {
|
||||
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
|
||||
}, [fields]);
|
||||
}, [fieldsRequiringValidation]);
|
||||
|
||||
const uninsertedRecipientFields = useMemo(() => {
|
||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
||||
}, [fieldsRequiringValidation, recipient]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
@@ -88,12 +116,31 @@ export const SigningForm = ({
|
||||
}
|
||||
|
||||
await completeDocument();
|
||||
};
|
||||
|
||||
// Reauth is currently not required for completing the document.
|
||||
// await executeActionAuthProcedure({
|
||||
// onReauthFormSubmit: completeDocument,
|
||||
// actionTarget: 'DOCUMENT',
|
||||
// });
|
||||
const onAssistantFormSubmit = () => {
|
||||
if (uninsertedRecipientFields.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAssistantConfirmDialogSubmit = async () => {
|
||||
setIsAssistantSubmitting(true);
|
||||
|
||||
try {
|
||||
await completeDocument();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while completing the document. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
setIsAssistantSubmitting(false);
|
||||
setIsConfirmationDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
||||
@@ -113,7 +160,7 @@ export const SigningForm = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
<div
|
||||
className={cn(
|
||||
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
|
||||
{
|
||||
@@ -121,7 +168,6 @@ export const SigningForm = ({
|
||||
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !session,
|
||||
},
|
||||
)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
@@ -129,17 +175,13 @@ export const SigningForm = ({
|
||||
</FieldToolTip>
|
||||
)}
|
||||
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex flex-1 flex-col')}>
|
||||
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
|
||||
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
|
||||
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
|
||||
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
|
||||
</h3>
|
||||
|
||||
{recipient.role === RecipientRole.VIEWER ? (
|
||||
@@ -176,91 +218,185 @@ export const SigningForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : recipient.role === RecipientRole.ASSISTANT ? (
|
||||
<>
|
||||
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
Complete the fields for the following signers. Once reviewed, they will inform
|
||||
you if any modifications are needed.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border my-4" />
|
||||
|
||||
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
|
||||
<Controller
|
||||
name="selectedSignerId"
|
||||
control={assistantForm.control}
|
||||
rules={{ required: 'Please select a signer' }}
|
||||
render={({ field }) => (
|
||||
<RadioGroup
|
||||
className="gap-0 space-y-3 shadow-none"
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
setSelectedSignerId?.(Number(value));
|
||||
}}
|
||||
>
|
||||
{allRecipients
|
||||
.filter((r) => r.fields.length > 0)
|
||||
.map((r) => (
|
||||
<div
|
||||
key={`${assistantSignersId}-${r.id}`}
|
||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
id={`${assistantSignersId}-${r.id}`}
|
||||
value={r.id.toString()}
|
||||
className="after:absolute after:inset-0"
|
||||
/>
|
||||
|
||||
<div className="grid grow gap-1">
|
||||
<Label
|
||||
className="inline-flex items-start"
|
||||
htmlFor={`${assistantSignersId}-${r.id}`}
|
||||
>
|
||||
{r.name}
|
||||
|
||||
{r.id === recipient.id && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{_(msg`(You)`)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={isAssistantSubmitting}
|
||||
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
|
||||
>
|
||||
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AssistantConfirmationDialog
|
||||
hasUninsertedFields={uninsertedFields.length > 0}
|
||||
isOpen={isConfirmationDialogOpen}
|
||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
||||
isSubmitting={isAssistantSubmitting}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
</p>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (signatureValid) {
|
||||
setSignature(value);
|
||||
}
|
||||
}}
|
||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
disabled={!isRecipientsTurn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (signatureValid) {
|
||||
setSignature(value);
|
||||
}
|
||||
}}
|
||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
disabled={!isRecipientsTurn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -22,26 +21,22 @@ import type {
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type InitialsFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const InitialsField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: InitialsFieldProps) => {
|
||||
export const InitialsField = ({ field, onSignField, onUnsignField }: InitialsFieldProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { fullName } = useRequiredSigningContext();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
const initials = extractInitials(fullName);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -87,7 +82,9 @@ export const InitialsField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZNameFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -28,16 +27,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type NameFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => {
|
||||
export const NameField = ({ field, onSignField, onUnsignField }: NameFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { _ } = useLingui();
|
||||
@@ -45,6 +44,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
|
||||
const { fullName: providedFullName, setFullName: setProvidedFullName } =
|
||||
useRequiredSigningContext();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
@@ -67,7 +67,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
const [localFullName, setLocalFullName] = useState('');
|
||||
|
||||
const onPreSign = () => {
|
||||
if (!providedFullName) {
|
||||
if (!providedFullName && !isAssistantMode) {
|
||||
setShowFullNameModal(true);
|
||||
return false;
|
||||
}
|
||||
@@ -90,9 +90,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
|
||||
try {
|
||||
const value = name || providedFullName;
|
||||
const value = name || providedFullName || '';
|
||||
|
||||
if (!value) {
|
||||
if (!value && !isAssistantMode) {
|
||||
setShowFullNameModal(true);
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +124,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -150,7 +152,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -27,6 +26,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
type ValidationErrors = {
|
||||
@@ -39,18 +39,18 @@ type ValidationErrors = {
|
||||
|
||||
export type NumberFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => {
|
||||
export const NumberField = ({ field, onSignField, onUnsignField }: NumberFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [showRadioModal, setShowRadioModal] = useState(false);
|
||||
const [showNumberModal, setShowNumberModal] = useState(false);
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
@@ -105,7 +105,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
};
|
||||
|
||||
const onDialogSignClick = () => {
|
||||
setShowRadioModal(false);
|
||||
setShowNumberModal(false);
|
||||
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
@@ -148,14 +148,20 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onPreSign = () => {
|
||||
setShowRadioModal(true);
|
||||
if (isAssistantMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setShowNumberModal(true);
|
||||
|
||||
if (localNumber && parsedFieldMeta) {
|
||||
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
|
||||
@@ -193,18 +199,18 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRadioModal) {
|
||||
if (!showNumberModal) {
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
|
||||
setErrors(initialErrors);
|
||||
}
|
||||
}, [showRadioModal]);
|
||||
}, [showNumberModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -222,8 +228,8 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
|
||||
if (parsedFieldMeta?.label) {
|
||||
fieldDisplayName =
|
||||
parsedFieldMeta.label.length > 10
|
||||
? parsedFieldMeta.label.substring(0, 10) + '...'
|
||||
parsedFieldMeta.label.length > 20
|
||||
? parsedFieldMeta.label.substring(0, 20) + '...'
|
||||
: parsedFieldMeta.label;
|
||||
}
|
||||
|
||||
@@ -235,7 +241,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
onPreSign={onPreSign}
|
||||
onSign={onSign}
|
||||
onRemove={onRemove}
|
||||
type="Signature"
|
||||
type="Number"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
@@ -278,7 +284,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
|
||||
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}
|
||||
@@ -334,7 +340,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowRadioModal(false);
|
||||
setShowNumberModal(false);
|
||||
setLocalNumber('');
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,11 +12,12 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { DocumentAuthProvider } from './document-auth-provider';
|
||||
import { NoLongerAvailable } from './no-longer-available';
|
||||
@@ -43,14 +44,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
|
||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||
|
||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||
const [document, recipient, fields, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
userId: user?.id,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getCompletedFieldsForToken({ token }),
|
||||
]);
|
||||
|
||||
@@ -63,12 +64,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const recipientWithFields = { ...recipient, fields };
|
||||
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
return redirect(`/sign/${token}/waiting`);
|
||||
}
|
||||
|
||||
const allRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? await getRecipientsForAssistant({
|
||||
token,
|
||||
})
|
||||
: [];
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
@@ -153,11 +163,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
user={user}
|
||||
>
|
||||
<SigningPageView
|
||||
recipient={recipient}
|
||||
recipient={recipientWithFields}
|
||||
document={document}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -24,18 +23,19 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type RadioFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => {
|
||||
export const RadioField = ({ field, onSignField, onUnsignField }: RadioFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -99,7 +99,9 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -126,7 +128,7 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the selection.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { type PropsWithChildren, createContext, useContext } from 'react';
|
||||
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
|
||||
export interface RecipientContextValue {
|
||||
/**
|
||||
* The recipient who is currently signing the document.
|
||||
* In regular mode, this is the actual signer.
|
||||
* In assistant mode, this is the recipient who is helping fill out the document.
|
||||
*/
|
||||
recipient: Recipient | RecipientWithFields;
|
||||
|
||||
/**
|
||||
* Only present in assistant mode.
|
||||
* The recipient on whose behalf we're filling out the document.
|
||||
*/
|
||||
targetSigner: RecipientWithFields | null;
|
||||
|
||||
/**
|
||||
* Whether we're in assistant mode (one recipient filling out for another)
|
||||
*/
|
||||
isAssistantMode: boolean;
|
||||
}
|
||||
|
||||
const RecipientContext = createContext<RecipientContextValue | null>(null);
|
||||
|
||||
export interface RecipientProviderProps extends PropsWithChildren {
|
||||
recipient: Recipient | RecipientWithFields;
|
||||
targetSigner?: RecipientWithFields | null;
|
||||
}
|
||||
|
||||
export const RecipientProvider = ({
|
||||
children,
|
||||
recipient,
|
||||
targetSigner = null,
|
||||
}: RecipientProviderProps) => {
|
||||
// console.log({
|
||||
// recipient,
|
||||
// targetSigner,
|
||||
// isAssistantMode: !!targetSigner,
|
||||
// });
|
||||
return (
|
||||
<RecipientContext.Provider
|
||||
value={{
|
||||
recipient,
|
||||
targetSigner,
|
||||
isAssistantMode: !!targetSigner,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecipientContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useRecipientContext() {
|
||||
const context = useContext(RecipientContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useRecipientContext must be used within a RecipientProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { Loader } from 'lucide-react';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -28,12 +27,12 @@ import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
typedSignatureEnabled?: boolean;
|
||||
@@ -41,15 +40,14 @@ export type SignatureFieldProps = {
|
||||
|
||||
export const SignatureField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
typedSignatureEnabled,
|
||||
}: SignatureFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient } = useRecipientContext();
|
||||
|
||||
const signatureRef = useRef<HTMLParagraphElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -46,6 +46,7 @@ export type SignatureFieldProps = {
|
||||
| 'Email'
|
||||
| 'Name'
|
||||
| 'Signature'
|
||||
| 'Text'
|
||||
| 'Radio'
|
||||
| 'Dropdown'
|
||||
| 'Number'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -13,9 +17,10 @@ import {
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
@@ -32,16 +37,18 @@ import { InitialsField } from './initials-field';
|
||||
import { NameField } from './name-field';
|
||||
import { NumberField } from './number-field';
|
||||
import { RadioField } from './radio-field';
|
||||
import { RecipientProvider } from './recipient-context';
|
||||
import { RejectDocumentDialog } from './reject-document-dialog';
|
||||
import { SignatureField } from './signature-field';
|
||||
import { TextField } from './text-field';
|
||||
|
||||
export type SigningPageViewProps = {
|
||||
document: DocumentAndSender;
|
||||
recipient: Recipient;
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: CompletedField[];
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
};
|
||||
|
||||
export const SigningPageView = ({
|
||||
@@ -50,9 +57,12 @@ export const SigningPageView = ({
|
||||
fields,
|
||||
completedFields,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
}: SigningPageViewProps) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
|
||||
const shouldUseTeamDetails =
|
||||
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
|
||||
|
||||
@@ -64,153 +74,168 @@ export const SigningPageView = ({
|
||||
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
|
||||
}
|
||||
|
||||
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to view this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to view this document</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.SIGNER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to sign this document</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.APPROVER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to approve this document</Trans>
|
||||
),
|
||||
)
|
||||
.otherwise(() => null)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RejectDocumentDialog document={document} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
gradient
|
||||
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||
<SigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
/>
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to view this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to view this document</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.SIGNER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to sign this document</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.APPROVER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to approve this document</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.ASSISTANT, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to assist this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to assist this document</Trans>
|
||||
),
|
||||
)
|
||||
.otherwise(() => null)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RejectDocumentDialog document={document} token={recipient.token} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentReadOnlyFields fields={completedFields} />
|
||||
|
||||
<AutoSign recipient={recipient} fields={fields} />
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
<InitialsField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.NAME, () => (
|
||||
<NameField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.TEXT, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <TextField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.NUMBER, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <NumberField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.RADIO, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <RadioField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.CHECKBOX, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <CheckboxField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.DROPDOWN, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <DropdownField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.otherwise(() => null),
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||
<SigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentReadOnlyFields fields={completedFields} />
|
||||
|
||||
{recipient.role !== RecipientRole.ASSISTANT && (
|
||||
<AutoSign recipient={recipient} fields={fields} />
|
||||
)}
|
||||
</ElementVisible>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields
|
||||
.filter(
|
||||
(field) =>
|
||||
recipient.role !== RecipientRole.ASSISTANT ||
|
||||
field.recipientId === selectedSigner?.id,
|
||||
)
|
||||
.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => <InitialsField key={field.id} field={field} />)
|
||||
.with(FieldType.NAME, () => <NameField key={field.id} field={field} />)
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => <EmailField key={field.id} field={field} />)
|
||||
.with(FieldType.TEXT, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <TextField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.with(FieldType.NUMBER, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <NumberField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.with(FieldType.RADIO, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <RadioField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.with(FieldType.CHECKBOX, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <CheckboxField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.with(FieldType.DROPDOWN, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <DropdownField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
</div>
|
||||
</RecipientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZTextFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -27,26 +26,31 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
type ValidationErrors = {
|
||||
required: string[];
|
||||
characterLimit: string[];
|
||||
};
|
||||
|
||||
export type TextFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
|
||||
export const TextField = ({ field, onSignField, onUnsignField }: TextFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const initialErrors: Record<string, string[]> = {
|
||||
const initialErrors: ValidationErrors = {
|
||||
required: [],
|
||||
characterLimit: [],
|
||||
};
|
||||
|
||||
const [errors, setErrors] = useState(initialErrors);
|
||||
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
|
||||
|
||||
@@ -166,7 +170,9 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -194,7 +200,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the text.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -234,7 +240,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
onPreSign={onPreSign}
|
||||
onSign={onSign}
|
||||
onRemove={onRemove}
|
||||
type="Signature"
|
||||
type="Text"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
@@ -276,7 +282,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
>
|
||||
{field.customText.length < 20
|
||||
? field.customText
|
||||
: field.customText.substring(0, 15) + '...'}
|
||||
: field.customText.substring(0, 20) + '...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type EmbedDocumentCompletedPageProps = {
|
||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||
console.log({ signature });
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Document Completed!</Trans>
|
||||
</h3>
|
||||
|
||||
@@ -485,7 +485,6 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields
|
||||
recipient={recipient}
|
||||
fields={localFields}
|
||||
metadata={metadata}
|
||||
onSignField={onSignField}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
|
||||
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
|
||||
|
||||
import { EmbedAuthenticateView } from '../../authenticate';
|
||||
import { EmbedPaywall } from '../../paywall';
|
||||
@@ -96,16 +97,18 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EmbedDirectTemplateClientPage
|
||||
token={token}
|
||||
updatedAt={template.updatedAt}
|
||||
documentData={template.templateDocumentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
/>
|
||||
<RecipientProvider recipient={recipient}>
|
||||
<EmbedDirectTemplateClientPage
|
||||
token={token}
|
||||
updatedAt={template.updatedAt}
|
||||
documentData={template.templateDocumentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
/>
|
||||
</RecipientProvider>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
|
||||
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type Field, FieldType } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type {
|
||||
@@ -33,7 +33,6 @@ import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
|
||||
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
|
||||
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
@@ -41,7 +40,6 @@ export type EmbedDocumentFieldsProps = {
|
||||
};
|
||||
|
||||
export const EmbedDocumentFields = ({
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
onSignField,
|
||||
@@ -55,7 +53,6 @@ export const EmbedDocumentFields = ({
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
@@ -65,7 +62,6 @@ export const EmbedDocumentFields = ({
|
||||
<InitialsField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -74,7 +70,6 @@ export const EmbedDocumentFields = ({
|
||||
<NameField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -83,7 +78,6 @@ export const EmbedDocumentFields = ({
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
dateFormat={metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
@@ -94,7 +88,6 @@ export const EmbedDocumentFields = ({
|
||||
<EmailField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -109,7 +102,6 @@ export const EmbedDocumentFields = ({
|
||||
<TextField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -125,7 +117,6 @@ export const EmbedDocumentFields = ({
|
||||
<NumberField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -141,7 +132,6 @@ export const EmbedDocumentFields = ({
|
||||
<RadioField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -157,7 +147,6 @@ export const EmbedDocumentFields = ({
|
||||
<CheckboxField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -173,7 +162,6 @@ export const EmbedDocumentFields = ({
|
||||
<DropdownField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useEffect, useId, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@@ -9,8 +9,9 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
|
||||
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type DocumentData, type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -19,10 +20,12 @@ import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
|
||||
import { EmbedClientLoading } from '../../client-loading';
|
||||
@@ -35,12 +38,13 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
@@ -53,6 +57,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
isCompleted,
|
||||
hidePoweredBy = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
allRecipients = [],
|
||||
}: EmbedSignDocumentClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -70,17 +75,21 @@ export const EmbedSignDocumentClientPage = ({
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
||||
allRecipients.length > 0 ? allRecipients[0].id : null,
|
||||
);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
|
||||
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
||||
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
||||
|
||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
fields.filter((field) => !field.inserted),
|
||||
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
@@ -89,6 +98,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const onNextFieldClick = () => {
|
||||
validateFieldsInserted(fields);
|
||||
|
||||
@@ -214,164 +225,234 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<LazyPDFViewer
|
||||
documentData={documentData}
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<LazyPDFViewer
|
||||
documentData={documentData}
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div className="embed--DocumentWidgetHeader">
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
{isAssistantMode ? (
|
||||
<Trans>Assist with signing</Trans>
|
||||
) : (
|
||||
<Trans>Sign document</Trans>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{isAssistantMode ? (
|
||||
<Trans>Help complete the document for other signers.</Trans>
|
||||
) : (
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
{isAssistantMode && (
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Signing for</Trans>
|
||||
</Label>
|
||||
|
||||
<fieldset className="dark:bg-background border-border mt-2 rounded-2xl border bg-white p-3">
|
||||
<RadioGroup
|
||||
className="gap-0 space-y-3 shadow-none"
|
||||
value={selectedSignerId?.toString()}
|
||||
onValueChange={(value) => setSelectedSignerId(Number(value))}
|
||||
>
|
||||
{allRecipients
|
||||
.filter((r) => r.fields.length > 0)
|
||||
.map((r) => (
|
||||
<div
|
||||
key={`${assistantSignersId}-${r.id}`}
|
||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
id={`${assistantSignersId}-${r.id}`}
|
||||
value={r.id.toString()}
|
||||
className="after:absolute after:inset-0"
|
||||
/>
|
||||
|
||||
<div className="grid grow gap-1">
|
||||
<Label
|
||||
className="inline-flex items-start"
|
||||
htmlFor={`${assistantSignersId}-${r.id}`}
|
||||
>
|
||||
{r.name}
|
||||
|
||||
{r.id === recipient.id && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{_(msg`(You)`)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAssistantMode && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||
|
||||
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
||||
{pendingFields.length > 0 ? (
|
||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="col-start-2"
|
||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
||||
{pendingFields.length > 0 ? (
|
||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="col-start-2"
|
||||
disabled={
|
||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||
}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</RecipientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,17 +8,20 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
|
||||
|
||||
import { EmbedAuthenticateView } from '../../authenticate';
|
||||
import { EmbedPaywall } from '../../paywall';
|
||||
import { EmbedWaitingForTurn } from '../../waiting-for-turn';
|
||||
import { EmbedSignDocumentClientPage } from './client';
|
||||
|
||||
export type EmbedSignDocumentPageProps = {
|
||||
@@ -85,6 +88,19 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
);
|
||||
}
|
||||
|
||||
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
||||
|
||||
if (!isRecipientsTurnToSign) {
|
||||
return <EmbedWaitingForTurn />;
|
||||
}
|
||||
|
||||
const allRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? await getRecipientsForAssistant({
|
||||
token,
|
||||
})
|
||||
: [];
|
||||
|
||||
const team = document.teamId
|
||||
? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null)
|
||||
: null;
|
||||
@@ -112,6 +128,7 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
isCompleted={document.status === DocumentStatus.COMPLETED}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
allRecipients={allRecipients}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
export const EmbedWaitingForTurn = () => {
|
||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.parent && !hasPostedMessage) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-waiting-for-turn',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasPostedMessage(true);
|
||||
}, [hasPostedMessage]);
|
||||
|
||||
if (!hasPostedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-foreground text-center text-2xl font-bold">
|
||||
<Trans>Waiting for Your Turn</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mt-8 max-w-[50ch] text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
It's currently not your turn to sign. Please check back soon as this document should be
|
||||
available for you to sign shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
<Trans>Please check with the parent application for more information.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -85,7 +85,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
|
||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||
trpcReact.document.searchDocuments.useQuery(
|
||||
{
|
||||
query: search,
|
||||
|
||||
@@ -67,7 +67,7 @@ export const TransferTeamDialog = ({
|
||||
const {
|
||||
data,
|
||||
refetch: refetchTeamMembers,
|
||||
isLoading: loadingTeamMembers,
|
||||
isPending: loadingTeamMembers,
|
||||
isLoadingError: loadingTeamMembersError,
|
||||
} = trpc.team.getTeamMembers.useQuery({
|
||||
teamId,
|
||||
|
||||
@@ -353,6 +353,16 @@ export const DocumentHistorySheet = ({
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Field prefilled',
|
||||
value: formatGenericText(data.field.type),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.exhaustive()}
|
||||
|
||||
{isUserDetailsVisible && (
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.1",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -106,7 +106,7 @@
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@documenso/web",
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.1",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.1",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=@documenso/web",
|
||||
|
||||
@@ -270,12 +270,11 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.map((signer) => signer.email.toLowerCase());
|
||||
const ids = schema.map((signer) => signer.id);
|
||||
|
||||
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
|
||||
return new Set(ids).size === ids.length;
|
||||
},
|
||||
{ message: 'Recipient IDs and emails must be unique' },
|
||||
{ message: 'Recipient IDs must be unique' },
|
||||
),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[DOCUMENT_FLOW]: add signature fields for unique recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
// Advanced settings should not be visible for non EE users.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('**/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('**/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: add signature fields for recipients with different roles', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add a signer
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add an approver
|
||||
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a viewer
|
||||
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a CC
|
||||
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for signer and approver
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('**/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// First recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Second recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Third recipient (unique)
|
||||
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fourth recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fifth recipient (unique)
|
||||
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByLabel('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for approver and signers
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('**/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
@@ -91,3 +91,191 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: add only recipients with the same email address', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: duplicate email recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add a signer
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add an approver
|
||||
await page.getByLabel('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a viewer
|
||||
await page.getByLabel('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Add a CC
|
||||
await page.getByLabel('Email').nth(3).fill('recipient@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: same email with different roles', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// First recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Second recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Third recipient (unique)
|
||||
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fourth recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fifth recipient (unique)
|
||||
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByLabel('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: mixed unique and duplicate recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// First recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Second recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Third recipient (unique)
|
||||
await page.getByLabel('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByLabel('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fourth recipient (duplicate of first)
|
||||
await page.getByLabel('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByLabel('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
// Fifth recipient (unique)
|
||||
await page.getByLabel('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByLabel('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -540,12 +540,19 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
if (i > 1) {
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
}
|
||||
|
||||
await page
|
||||
.getByPlaceholder('Email')
|
||||
.getByLabel('Email')
|
||||
.nth(i - 1)
|
||||
.focus();
|
||||
|
||||
await page
|
||||
.getByLabel('Email')
|
||||
.nth(i - 1)
|
||||
.fill(`user${i}@example.com`);
|
||||
|
||||
await page
|
||||
.getByPlaceholder('Name')
|
||||
.getByLabel('Name')
|
||||
.nth(i - 1)
|
||||
.fill(`User ${i}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEMPLATE_FLOW]: add signature fields for unique recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Add 2 placeholder recipients.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||
|
||||
// Advanced settings should not be visible for non EE users.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL('**/templates');
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: add signature fields for duplicate recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient (recipient@documenso.com)' }).nth(2).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL('**/templates');
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: add signature fields for recipients with different roles', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// Add a placeholder recipient
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add an approver
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add a viewer
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add a CC
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for signer and approver
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Documenso Recipient (recipient@documenso.com)' })
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL('**/templates');
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: add signature fields for mixed recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||
|
||||
// First placeholder recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Second placeholder recipient (duplicate of first)
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Third placeholder recipient (unique)
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Fourth placeholder recipient (duplicate of first)
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Fifth placeholder recipient (unique)
|
||||
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for approver and signers
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Second Recipient (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Third Recipient (recipient3@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save Template' }).click();
|
||||
|
||||
await page.waitForURL('**/templates');
|
||||
|
||||
await expect(page.getByRole('link', { name: template.title })).toBeVisible();
|
||||
});
|
||||
@@ -98,3 +98,135 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
|
||||
// Advanced settings should not be visible for non EE users.
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: duplicate recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Recipient');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: same email different roles', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add a signer
|
||||
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add an approver
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add a viewer
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Add a CC
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('Documenso Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATE_FLOW]: mixed recipients', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// First recipient (unique)
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('First Recipient');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Second recipient (duplicate of first)
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(1).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Third recipient (unique)
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Second Recipient');
|
||||
await page.getByRole('combobox').nth(2).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Fourth recipient (duplicate of first)
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('First Recipient');
|
||||
await page.getByRole('combobox').nth(3).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
// Fifth recipient (unique)
|
||||
await page.getByPlaceholder('Email').nth(4).fill('recipient3@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(4).fill('Third Recipient');
|
||||
await page.getByRole('combobox').nth(4).click();
|
||||
await page.getByLabel('Needs to sign').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -110,14 +110,13 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
||||
await page.getByRole('button', { name: 'Save template' }).click();
|
||||
|
||||
// Use template
|
||||
await page.waitForURL('/templates');
|
||||
await page.waitForURL('**/templates');
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -250,9 +249,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -353,9 +351,8 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the custom document data
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -434,9 +431,8 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the custom document data
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -500,9 +496,8 @@ test('[TEMPLATE]: should create a document from a template using template docume
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the template's document data
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -591,9 +586,8 @@ test('[TEMPLATE]: should persist document visibility when creating from template
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct visibility
|
||||
await page.waitForURL(/documents/);
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -616,3 +610,368 @@ test('[TEMPLATE]: should persist document visibility when creating from template
|
||||
// Template should not be visible to regular member
|
||||
await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* This test verifies that we can create a document from a template with duplicate recipients
|
||||
**/
|
||||
test('[TEMPLATE]: should create a document from a template with duplicate recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
const isBillingEnabled =
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||
|
||||
await seedUserSubscription({
|
||||
userId: user.id,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Set template title.
|
||||
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
|
||||
|
||||
// Set template document access.
|
||||
await page.getByTestId('documentAccessSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Set EE action auth.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
}
|
||||
|
||||
// Set email options.
|
||||
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 1');
|
||||
|
||||
// Apply require passkey for Recipient 1.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByLabel('Show advanced settings').check();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByLabel('Require passkey').click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save template' }).click();
|
||||
|
||||
// Use template
|
||||
await page.waitForURL('**/templates');
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const documentAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||
isBillingEnabled ? 'PASSKEY' : null,
|
||||
);
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||
|
||||
const recipientOne = document.recipients[0];
|
||||
const recipientTwo = document.recipients[1];
|
||||
|
||||
const recipientOneAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipientOne.authOptions,
|
||||
});
|
||||
|
||||
const recipientTwoAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipientTwo.authOptions,
|
||||
});
|
||||
|
||||
if (isBillingEnabled) {
|
||||
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||
}
|
||||
|
||||
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
|
||||
await page.getByRole('link', { name: 'Edit' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await expect(page.getByText('SignatureRE').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
|
||||
|
||||
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* This test verifies that we can create a document from a template with a mix of duplicate and unique recipients
|
||||
**/
|
||||
test('[TEMPLATE]: should create a document from a template with mixed duplicate and unique recipients', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const template = await seedBlankTemplate(user);
|
||||
|
||||
const isBillingEnabled =
|
||||
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||
|
||||
await seedUserSubscription({
|
||||
userId: user.id,
|
||||
priceId: enterprisePriceId,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Set template title.
|
||||
await page.getByLabel('Title').fill('TEMPLATE_MIXED_RECIPIENTS');
|
||||
|
||||
// Set template document access.
|
||||
await page.getByTestId('documentAccessSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Set EE action auth.
|
||||
if (isBillingEnabled) {
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||
}
|
||||
|
||||
// Set email options.
|
||||
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||
|
||||
// Add 4 signers: 2 duplicates of recipient1 and 2 unique recipients
|
||||
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(2).fill('recipient1@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(2).fill('Recipient 1');
|
||||
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||
|
||||
await page.getByPlaceholder('Email').nth(3).fill('recipient3@documenso.com');
|
||||
await page.getByPlaceholder('Name').nth(3).fill('Recipient 3');
|
||||
|
||||
// Apply require passkey for first instance of Recipient 1
|
||||
if (isBillingEnabled) {
|
||||
await page.getByLabel('Show advanced settings').check();
|
||||
await page.getByRole('combobox').first().click();
|
||||
await page.getByLabel('Require passkey').click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
// Add signature fields for each recipient
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 200,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 300,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Recipient 3 (recipient3@documenso.com)' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 400,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Save template' }).click();
|
||||
|
||||
// Use template
|
||||
await page.waitForURL('**/templates');
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
const documentId = Number(page.url().match(/\/documents\/(\d+)/)?.[1]);
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
orderBy: {
|
||||
email: 'asc',
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const documentAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_MIXED_RECIPIENTS');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||
isBillingEnabled ? 'PASSKEY' : null,
|
||||
);
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||
|
||||
// Check auth settings for first instance of recipient1
|
||||
const firstRecipientOne = document.recipients[0];
|
||||
const firstRecipientOneAuth = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: firstRecipientOne.authOptions,
|
||||
});
|
||||
|
||||
if (isBillingEnabled) {
|
||||
expect(firstRecipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||
}
|
||||
|
||||
expect(firstRecipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||
|
||||
await page.getByRole('link', { name: 'Edit' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await expect(page.getByText('SignatureRE').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'recipient2@documenso.com' }).click();
|
||||
await expect(page.getByText('SignatureRE').nth(1)).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'recipient1@documenso.com' }).nth(1).click();
|
||||
await expect(page.getByText('SignatureRE').nth(2)).toBeVisible();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'recipient3@documenso.com' }).click();
|
||||
await expect(page.getByText('SignatureRE').nth(3)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -84,6 +84,9 @@ export const TemplateDocumentInvite = ({
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<Trans>Continue by assisting with the document.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Text>
|
||||
|
||||
@@ -104,6 +107,7 @@ export const TemplateDocumentInvite = ({
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
@@ -10,6 +10,9 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
|
||||
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
|
||||
description: 'Approval request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.ASSISTING_REQUEST]: {
|
||||
description: 'Assisting request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.CC]: {
|
||||
description: 'CC',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es'] as const;
|
||||
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es', 'it', 'pl'] as const;
|
||||
|
||||
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
|
||||
|
||||
@@ -46,6 +46,14 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
|
||||
full: 'Spanish',
|
||||
short: 'es',
|
||||
},
|
||||
it: {
|
||||
full: 'Italian',
|
||||
short: 'it',
|
||||
},
|
||||
pl: {
|
||||
short: 'pl',
|
||||
full: 'Polish',
|
||||
},
|
||||
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;
|
||||
|
||||
export const isValidLanguageCode = (code: unknown): code is SupportedLanguageCodes =>
|
||||
|
||||
@@ -32,12 +32,26 @@ export const RECIPIENT_ROLES_DESCRIPTION = {
|
||||
roleName: msg`Viewer`,
|
||||
roleNamePlural: msg`Viewers`,
|
||||
},
|
||||
[RecipientRole.ASSISTANT]: {
|
||||
actionVerb: msg`Assist`,
|
||||
actioned: msg`Assisted`,
|
||||
progressiveVerb: msg`Assisting`,
|
||||
roleName: msg`Assistant`,
|
||||
roleNamePlural: msg`Assistants`,
|
||||
},
|
||||
} satisfies Record<keyof typeof RecipientRole, unknown>;
|
||||
|
||||
export const RECIPIENT_ROLE_TO_DISPLAY_TYPE = {
|
||||
[RecipientRole.SIGNER]: `SIGNING_REQUEST`,
|
||||
[RecipientRole.VIEWER]: `VIEW_REQUEST`,
|
||||
[RecipientRole.APPROVER]: `APPROVE_REQUEST`,
|
||||
} as const;
|
||||
|
||||
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||
[RecipientRole.SIGNER]: `SIGNING_REQUEST`,
|
||||
[RecipientRole.VIEWER]: `VIEW_REQUEST`,
|
||||
[RecipientRole.APPROVER]: `APPROVE_REQUEST`,
|
||||
[RecipientRole.ASSISTANT]: `ASSISTING_REQUEST`,
|
||||
} as const;
|
||||
|
||||
export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||
@@ -45,4 +59,5 @@ export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||
[RecipientRole.APPROVER]: msg`I am an approver of this document`,
|
||||
[RecipientRole.CC]: msg`I am required to receive a copy of this document`,
|
||||
[RecipientRole.VIEWER]: msg`I am a viewer of this document`,
|
||||
[RecipientRole.ASSISTANT]: msg`I am an assistant of this document`,
|
||||
} satisfies Record<keyof typeof RecipientRole, MessageDescriptor>;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentStatus,
|
||||
SubscriptionStatus,
|
||||
UserSecurityAuditLogType,
|
||||
} from '@documenso/prisma/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getUsersCount = async () => {
|
||||
return await prisma.user.count();
|
||||
@@ -84,38 +80,3 @@ export const getUserWithSignedDocumentMonthlyGrowth = async () => {
|
||||
signed_count: Number(row.signed_count),
|
||||
}));
|
||||
};
|
||||
|
||||
export type GetMonthlyActiveUsersResult = Array<{
|
||||
month: string;
|
||||
count: number;
|
||||
cume_count: number;
|
||||
}>;
|
||||
|
||||
export const getMonthlyActiveUsers = async () => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('UserSecurityAuditLog')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']).as('month'),
|
||||
fn.count('userId').distinct().as('count'),
|
||||
fn
|
||||
.sum(fn.count('userId').distinct())
|
||||
.over((ob) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']) as any),
|
||||
)
|
||||
.as('cume_count'),
|
||||
])
|
||||
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
|
||||
.where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
|
||||
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']))
|
||||
.orderBy('month', 'desc')
|
||||
.limit(12);
|
||||
|
||||
const result = await qb.execute();
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
cume_count: Number(row.cume_count),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -14,8 +14,8 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
@@ -1,15 +1,55 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type GetFieldsForTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
return await prisma.field.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
type: {
|
||||
not: FieldType.SIGNATURE,
|
||||
},
|
||||
recipient: {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
},
|
||||
documentId: recipient.documentId,
|
||||
},
|
||||
{
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.field.findMany({
|
||||
where: {
|
||||
recipient: {
|
||||
token,
|
||||
},
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type RemovedSignedFieldWithTokenOptions = {
|
||||
token: string;
|
||||
@@ -17,11 +17,28 @@ export const removeSignedFieldWithToken = async ({
|
||||
fieldId,
|
||||
requestMetadata,
|
||||
}: RemovedSignedFieldWithTokenOptions) => {
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient: {
|
||||
token,
|
||||
...(recipient.role !== RecipientRole.ASSISTANT
|
||||
? {
|
||||
id: recipient.id,
|
||||
}
|
||||
: {
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@@ -30,7 +47,7 @@ export const removeSignedFieldWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const { document, recipient } = field;
|
||||
const { document } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
@@ -40,7 +57,10 @@ export const removeSignedFieldWithToken = async ({
|
||||
throw new Error(`Document ${document.id} must be pending`);
|
||||
}
|
||||
|
||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||
if (
|
||||
recipient?.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
@@ -66,20 +86,22 @@ export const removeSignedFieldWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient?.name,
|
||||
email: recipient?.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
field: field.type,
|
||||
fieldId: field.secondaryId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (recipient.role !== RecipientRole.ASSISTANT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
field: field.type,
|
||||
fieldId: field.secondaryId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -72,6 +72,22 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Check that every signer has a signature field
|
||||
const signers = document.recipients.filter((recipient) => recipient.role === 'SIGNER');
|
||||
const hasEverySignerSignature = signers.every((signer) =>
|
||||
fields.some(
|
||||
(field) =>
|
||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||
field.recipientId === signer.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (!hasEverySignerSignature) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Every signer must have at least one signature field',
|
||||
});
|
||||
}
|
||||
|
||||
if (document.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
@@ -94,9 +110,7 @@ export const setFieldsForDocument = async ({
|
||||
const linkedFields = fields.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
|
||||
const recipient = document.recipients.find(
|
||||
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
|
||||
);
|
||||
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
@@ -236,10 +250,8 @@ export const setFieldsForDocument = async ({
|
||||
},
|
||||
recipient: {
|
||||
connect: {
|
||||
documentId_email: {
|
||||
documentId,
|
||||
email: fieldSignerEmail,
|
||||
},
|
||||
documentId,
|
||||
id: field.recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -340,6 +352,7 @@ type FieldData = {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerEmail: string;
|
||||
recipientId: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
|
||||
@@ -22,6 +22,7 @@ export type SetFieldsForTemplateOptions = {
|
||||
fields: {
|
||||
id?: number | null;
|
||||
type: FieldType;
|
||||
signerId: number;
|
||||
signerEmail: string;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
@@ -57,12 +58,29 @@ export const setFieldsForTemplate = async ({
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
// Check that every signer has a signature field
|
||||
const signers = template.recipients.filter((recipient) => recipient.role === 'SIGNER');
|
||||
const hasEverySignerSignature = signers.every((signer) =>
|
||||
fields.some(
|
||||
(field) =>
|
||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||
field.signerId === signer.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (!hasEverySignerSignature) {
|
||||
throw new Error('Every signer must have at least one signature field');
|
||||
}
|
||||
|
||||
const existingFields = await prisma.field.findMany({
|
||||
where: {
|
||||
templateId,
|
||||
@@ -180,10 +198,8 @@ export const setFieldsForTemplate = async ({
|
||||
},
|
||||
recipient: {
|
||||
connect: {
|
||||
templateId_email: {
|
||||
templateId,
|
||||
email: field.signerEmail.toLowerCase(),
|
||||
},
|
||||
templateId,
|
||||
id: field.signerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||
@@ -56,20 +56,41 @@ export const signFieldWithToken = async ({
|
||||
authOptions,
|
||||
requestMetadata,
|
||||
}: SignFieldWithTokenOptions) => {
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient: {
|
||||
token,
|
||||
...(recipient.role !== RecipientRole.ASSISTANT
|
||||
? {
|
||||
id: recipient.id,
|
||||
}
|
||||
: {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
document: true,
|
||||
document: {
|
||||
include: {
|
||||
recipients: true,
|
||||
},
|
||||
},
|
||||
recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { document, recipient } = field;
|
||||
const { document } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
@@ -87,7 +108,10 @@ export const signFieldWithToken = async ({
|
||||
throw new Error(`Document ${document.id} must be pending for signing`);
|
||||
}
|
||||
|
||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||
if (
|
||||
recipient.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
@@ -183,6 +207,8 @@ export const signFieldWithToken = async ({
|
||||
throw new Error('Typed signatures are not allowed. Please draw your signature');
|
||||
}
|
||||
|
||||
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const updatedField = await tx.field.update({
|
||||
where: {
|
||||
@@ -219,11 +245,14 @@ export const signFieldWithToken = async ({
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
type:
|
||||
assistant && field.recipientId !== assistant.id
|
||||
? DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED
|
||||
: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
email: assistant?.email ?? recipient.email,
|
||||
name: assistant?.name ?? recipient.name,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
|
||||
@@ -9,5 +9,8 @@ export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions)
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export interface GetRecipientsForAssistantOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssistantOptions) => {
|
||||
const assistant = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!assistant) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Assistant not found',
|
||||
});
|
||||
}
|
||||
|
||||
let recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: assistant.documentId,
|
||||
signingOrder: {
|
||||
gte: assistant.signingOrder ?? 0,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
fields: {
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
recipientId: assistant.id,
|
||||
},
|
||||
{
|
||||
type: {
|
||||
not: FieldType.SIGNATURE,
|
||||
},
|
||||
documentId: assistant.documentId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Omit the token for recipients other than the assistant so
|
||||
// it doesn't get sent to the client.
|
||||
recipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
token: recipient.id === assistant.id ? token : '',
|
||||
}));
|
||||
|
||||
return recipients;
|
||||
};
|
||||
@@ -125,16 +125,12 @@ export const setDocumentRecipients = async ({
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||
);
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -103,10 +103,7 @@ export const setTemplateRecipients = async ({
|
||||
|
||||
const removedRecipients = existingRecipients.filter(
|
||||
(existingRecipient) =>
|
||||
!normalizedRecipients.find(
|
||||
(recipient) =>
|
||||
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
|
||||
),
|
||||
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
||||
);
|
||||
|
||||
if (template.directLink !== null) {
|
||||
@@ -133,14 +130,10 @@ export const setTemplateRecipients = async ({
|
||||
|
||||
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
(existingRecipient) => existingRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
_persisted: existing,
|
||||
};
|
||||
return { ...recipient, _persisted: existing };
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -141,10 +141,8 @@ export const createDocumentFromTemplateLegacy = async ({
|
||||
|
||||
return await prisma.recipient.upsert({
|
||||
where: {
|
||||
documentId_email: {
|
||||
documentId: document.id,
|
||||
email: existingRecipient?.email ?? recipient.email,
|
||||
},
|
||||
documentId: document.id,
|
||||
id: existingRecipient?.id,
|
||||
},
|
||||
update: {
|
||||
name: recipient.name,
|
||||
|
||||
@@ -256,10 +256,21 @@ export const createDocumentFromTemplate = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const recipientMapping = new Map<number, number>();
|
||||
|
||||
template.recipients.forEach((templateRecipient, index) => {
|
||||
const documentRecipient = document.recipients[index];
|
||||
|
||||
if (documentRecipient) {
|
||||
recipientMapping.set(templateRecipient.id, documentRecipient.id);
|
||||
}
|
||||
});
|
||||
|
||||
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
|
||||
|
||||
Object.values(finalRecipients).forEach(({ email, fields }) => {
|
||||
const recipient = document.recipients.find((recipient) => recipient.email === email);
|
||||
finalRecipients.forEach(({ templateRecipientId, fields }) => {
|
||||
const documentRecipientId = recipientMapping.get(templateRecipientId);
|
||||
const recipient = document.recipients.find((r) => r.id === documentRecipientId);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found.');
|
||||
|
||||
+255
-149
File diff suppressed because it is too large
Load Diff
+241
-136
File diff suppressed because it is too large
Load Diff
+258
-152
File diff suppressed because it is too large
Load Diff
+262
-156
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+734
-595
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_DELETED', // When the document is soft deleted.
|
||||
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
|
||||
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
|
||||
'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant.
|
||||
'DOCUMENT_VISIBILITY_UPDATED', // When the document visibility scope is updated
|
||||
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated.
|
||||
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
|
||||
@@ -45,6 +46,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
'SIGNING_REQUEST',
|
||||
'VIEW_REQUEST',
|
||||
'APPROVE_REQUEST',
|
||||
'ASSISTING_REQUEST',
|
||||
'CC',
|
||||
'DOCUMENT_COMPLETED',
|
||||
]);
|
||||
@@ -313,6 +315,83 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document field prefilled by assistant.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
fieldId: z.string(),
|
||||
|
||||
// Organised into union to allow us to extend each field if required.
|
||||
field: z.union([
|
||||
z.object({
|
||||
type: z.literal(FieldType.INITIALS),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.EMAIL),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.DATE),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.NAME),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.TEXT),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.RADIO),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.CHECKBOX),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.DROPDOWN),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.NUMBER),
|
||||
data: z.string(),
|
||||
}),
|
||||
]),
|
||||
fieldSecurity: z.preprocess(
|
||||
(input) => {
|
||||
const legacyNoneSecurityType = JSON.stringify({
|
||||
type: 'NONE',
|
||||
});
|
||||
|
||||
// Replace legacy 'NONE' field security type with undefined.
|
||||
if (
|
||||
typeof input === 'object' &&
|
||||
input !== null &&
|
||||
JSON.stringify(input) === legacyNoneSecurityType
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return input;
|
||||
},
|
||||
z
|
||||
.object({
|
||||
type: ZRecipientActionAuthTypesSchema,
|
||||
})
|
||||
.optional(),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventDocumentVisibilitySchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED),
|
||||
data: ZGenericFromToSchema,
|
||||
@@ -493,6 +572,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldPrefilledSchema,
|
||||
ZDocumentAuditLogEventDocumentVisibilitySchema,
|
||||
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,
|
||||
|
||||
@@ -314,6 +314,10 @@ export const formatDocumentAuditLogAction = (
|
||||
anonymous: msg`Field unsigned`,
|
||||
identified: msg`${prefix} unsigned a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
|
||||
anonymous: msg`Field prefilled by assistant`,
|
||||
identified: msg`${prefix} prefilled a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
|
||||
anonymous: msg`Document visibility updated`,
|
||||
identified: msg`${prefix} updated the document visibility`,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "RecipientRole" ADD VALUE 'ASSISTANT';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_documentId_email_key";
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_templateId_email_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_email_idx" ON "Recipient"("email");
|
||||
@@ -36,4 +36,4 @@
|
||||
"typescript": "5.6.2",
|
||||
"zod-prisma-types": "3.1.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,6 +417,7 @@ enum RecipientRole {
|
||||
SIGNER
|
||||
VIEWER
|
||||
APPROVER
|
||||
ASSISTANT
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
|
||||
@@ -442,11 +443,10 @@ model Recipient {
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
@@unique([documentId, email])
|
||||
@@unique([templateId, email])
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@index([token])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
enum FieldType {
|
||||
|
||||
@@ -5,6 +5,18 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||
|
||||
import { prisma } from '..';
|
||||
import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client';
|
||||
import { seedPendingDocument } from './documents';
|
||||
import { seedDirectTemplate, seedTemplate } from './templates';
|
||||
|
||||
const createDocumentData = async ({ documentData }: { documentData: string }) => {
|
||||
return prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: documentData,
|
||||
initialData: documentData,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const seedDatabase = async () => {
|
||||
const examplePdf = fs
|
||||
@@ -39,35 +51,80 @@ export const seedDatabase = async () => {
|
||||
update: {},
|
||||
});
|
||||
|
||||
const examplePdfData = await prisma.documentData.upsert({
|
||||
where: {
|
||||
id: 'clmn0kv5k0000pe04vcqg5zla',
|
||||
},
|
||||
create: {
|
||||
id: 'clmn0kv5k0000pe04vcqg5zla',
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: examplePdf,
|
||||
initialData: examplePdf,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const documentData = await createDocumentData({ documentData: examplePdf });
|
||||
|
||||
await prisma.document.create({
|
||||
data: {
|
||||
source: DocumentSource.DOCUMENT,
|
||||
title: 'Example Document',
|
||||
documentDataId: examplePdfData.id,
|
||||
userId: exampleUser.id,
|
||||
recipients: {
|
||||
create: {
|
||||
name: String(adminUser.name),
|
||||
email: adminUser.email,
|
||||
token: Math.random().toString(36).slice(2, 9),
|
||||
await prisma.document.create({
|
||||
data: {
|
||||
source: DocumentSource.DOCUMENT,
|
||||
title: `Example Document ${i}`,
|
||||
documentDataId: documentData.id,
|
||||
userId: exampleUser.id,
|
||||
recipients: {
|
||||
create: {
|
||||
name: String(adminUser.name),
|
||||
email: adminUser.email,
|
||||
token: Math.random().toString(36).slice(2, 9),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const documentData = await createDocumentData({ documentData: examplePdf });
|
||||
|
||||
await prisma.document.create({
|
||||
data: {
|
||||
source: DocumentSource.DOCUMENT,
|
||||
title: `Document ${i}`,
|
||||
documentDataId: documentData.id,
|
||||
userId: adminUser.id,
|
||||
recipients: {
|
||||
create: {
|
||||
name: String(exampleUser.name),
|
||||
email: exampleUser.email,
|
||||
token: Math.random().toString(36).slice(2, 9),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await seedPendingDocument(exampleUser, [adminUser], {
|
||||
key: 'example-pending',
|
||||
createDocumentOptions: {
|
||||
title: 'Pending Document',
|
||||
},
|
||||
});
|
||||
|
||||
await seedPendingDocument(adminUser, [exampleUser], {
|
||||
key: 'admin-pending',
|
||||
createDocumentOptions: {
|
||||
title: 'Pending Document',
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
seedTemplate({
|
||||
title: 'Template 1',
|
||||
userId: exampleUser.id,
|
||||
}),
|
||||
seedDirectTemplate({
|
||||
title: 'Direct Template 1',
|
||||
userId: exampleUser.id,
|
||||
}),
|
||||
|
||||
seedTemplate({
|
||||
title: 'Template 1',
|
||||
userId: adminUser.id,
|
||||
}),
|
||||
seedDirectTemplate({
|
||||
title: 'Direct Template 1',
|
||||
userId: adminUser.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const testUsers = [
|
||||
'test@documenso.com',
|
||||
'test2@documenso.com',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
|
||||
export type RecipientWithFields = Recipient & {
|
||||
fields: Field[];
|
||||
};
|
||||
@@ -192,14 +192,6 @@ export const ZCreateDocumentV2RequestSchema = z.object({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
)
|
||||
.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
|
||||
@@ -232,8 +232,9 @@ export const fieldRouter = router({
|
||||
teamId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerEmail: field.signerEmail,
|
||||
recipientId: field.recipientId,
|
||||
type: field.type,
|
||||
signerEmail: field.signerEmail,
|
||||
pageNumber: field.pageNumber,
|
||||
pageX: field.pageX,
|
||||
pageY: field.pageY,
|
||||
@@ -429,6 +430,7 @@ export const fieldRouter = router({
|
||||
teamId,
|
||||
fields: fields.map((field) => ({
|
||||
id: field.nativeId,
|
||||
signerId: field.recipientId,
|
||||
signerEmail: field.signerEmail,
|
||||
type: field.type,
|
||||
pageNumber: field.pageNumber,
|
||||
|
||||
@@ -112,13 +112,15 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
id: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
recipientId: z.number(),
|
||||
signerEmail: z.string().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
pageWidth: ZFieldWidthSchema,
|
||||
pageHeight: ZFieldHeightSchema,
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
@@ -136,6 +138,8 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number().min(1),
|
||||
signerId: z.number().min(1),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
@@ -49,16 +49,7 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
|
||||
|
||||
export const ZCreateDocumentRecipientsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(ZCreateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZCreateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZCreateDocumentRecipientsResponseSchema = z.object({
|
||||
@@ -74,18 +65,7 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
|
||||
|
||||
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(ZUpdateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients
|
||||
.filter((recipient) => recipient.email !== undefined)
|
||||
.map((recipient) => recipient.email?.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZUpdateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
|
||||
@@ -96,29 +76,19 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export const ZSetDocumentRecipientsRequestSchema = z
|
||||
.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
||||
);
|
||||
export const ZSetDocumentRecipientsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const ZSetDocumentRecipientsResponseSchema = z.object({
|
||||
recipients: ZRecipientLiteSchema.array(),
|
||||
@@ -133,16 +103,7 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
|
||||
|
||||
export const ZCreateTemplateRecipientsRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(ZCreateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZCreateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZCreateTemplateRecipientsResponseSchema = z.object({
|
||||
@@ -158,18 +119,7 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
|
||||
|
||||
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(ZUpdateRecipientSchema).refine(
|
||||
(recipients) => {
|
||||
const emails = recipients
|
||||
.filter((recipient) => recipient.email !== undefined)
|
||||
.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{
|
||||
message: 'Recipients must have unique emails',
|
||||
},
|
||||
),
|
||||
recipients: z.array(ZUpdateRecipientSchema),
|
||||
});
|
||||
|
||||
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
|
||||
@@ -180,29 +130,19 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export const ZSetTemplateRecipientsRequestSchema = z
|
||||
.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
|
||||
);
|
||||
export const ZSetTemplateRecipientsRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const ZSetTemplateRecipientsResponseSchema = z.object({
|
||||
recipients: ZRecipientLiteSchema.array(),
|
||||
|
||||
@@ -51,12 +51,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.describe('The information of the recipients to create the document with.')
|
||||
.refine((recipients) => {
|
||||
const emails = recipients.map((signer) => signer.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Recipients must have unique emails'),
|
||||
.describe('The information of the recipients to create the document with.'),
|
||||
distributeDocument: z
|
||||
.boolean()
|
||||
.describe('Whether to create the document as pending and distribute it to recipients.')
|
||||
|
||||
@@ -31,7 +31,8 @@ const getCardClassNames = (
|
||||
checkBoxOrRadio: boolean,
|
||||
cardClassName?: string,
|
||||
) => {
|
||||
const baseClasses = 'field-card-container relative z-20 h-full w-full transition-all';
|
||||
const baseClasses =
|
||||
'field--FieldRootContainer field-card-container relative z-20 h-full w-full transition-all';
|
||||
|
||||
const insertedClasses =
|
||||
'bg-primary/20 border-primary ring-primary/20 ring-offset-primary/20 ring-2 ring-offset-2 dark:shadow-none';
|
||||
@@ -141,6 +142,7 @@ export function FieldRootContainer({ field, children, cardClassName }: FieldCont
|
||||
<Card
|
||||
id={`field-${field.id}`}
|
||||
ref={ref}
|
||||
data-field-type={field.type}
|
||||
data-inserted={field.inserted ? 'true' : 'false'}
|
||||
className={cardClassNames}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import type { SelectProps } from '@radix-ui/react-select';
|
||||
@@ -11,12 +11,15 @@ import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type RecipientRoleSelectProps = SelectProps & {
|
||||
hideCCRecipients?: boolean;
|
||||
isAssistantEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSelectProps>(
|
||||
({ hideCCRecipients, ...props }, ref) => (
|
||||
({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => (
|
||||
<Select {...props}>
|
||||
<SelectTrigger ref={ref} className="bg-background w-[50px] p-2">
|
||||
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
|
||||
@@ -110,6 +113,42 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
|
||||
<SelectItem
|
||||
value={RecipientRole.ASSISTANT}
|
||||
disabled={!isAssistantEnabled}
|
||||
className={cn(
|
||||
!isAssistantEnabled &&
|
||||
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex w-[150px] items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
|
||||
<Trans>Can prepare</Trans>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<p>
|
||||
{isAssistantEnabled ? (
|
||||
<Trans>
|
||||
The recipient can prepare the document for later signers by pre-filling
|
||||
suggest values.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Assistant role is only available when the document is in sequential signing
|
||||
mode.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
),
|
||||
|
||||
@@ -92,6 +92,7 @@ export type FieldFormType = {
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
recipientId: number;
|
||||
signerEmail: string;
|
||||
fieldMeta?: FieldMeta;
|
||||
};
|
||||
@@ -143,6 +144,7 @@ export const AddFieldsFormPartial = ({
|
||||
pageY: Number(field.positionY),
|
||||
pageWidth: Number(field.width),
|
||||
pageHeight: Number(field.height),
|
||||
recipientId: field.recipientId,
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
@@ -348,6 +350,7 @@ export const AddFieldsFormPartial = ({
|
||||
pageY,
|
||||
pageWidth: fieldPageWidth,
|
||||
pageHeight: fieldPageHeight,
|
||||
recipientId: selectedSigner.id,
|
||||
signerEmail: selectedSigner.email,
|
||||
fieldMeta: undefined,
|
||||
};
|
||||
@@ -441,6 +444,7 @@ export const AddFieldsFormPartial = ({
|
||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
formId: nanoid(12),
|
||||
recipientId: selectedSigner?.id ?? lastActiveField.recipientId,
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
@@ -449,7 +453,7 @@ export const AddFieldsFormPartial = ({
|
||||
append(newField);
|
||||
}
|
||||
},
|
||||
[append, lastActiveField, selectedSigner?.email, toast],
|
||||
[append, lastActiveField, selectedSigner?.id, selectedSigner?.email, toast],
|
||||
);
|
||||
|
||||
const onFieldPaste = useCallback(
|
||||
@@ -462,13 +466,15 @@ export const AddFieldsFormPartial = ({
|
||||
append({
|
||||
...copiedField,
|
||||
formId: nanoid(12),
|
||||
recipientId: selectedSigner?.id ?? copiedField.recipientId,
|
||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||
|
||||
pageX: copiedField.pageX + 3,
|
||||
pageY: copiedField.pageY + 3,
|
||||
});
|
||||
}
|
||||
},
|
||||
[append, fieldClipboard, selectedSigner?.email],
|
||||
[append, fieldClipboard, selectedSigner?.id, selectedSigner?.email],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -508,7 +514,15 @@ export const AddFieldsFormPartial = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
|
||||
const recipientsByRoleToDisplay = recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||
);
|
||||
|
||||
setSelectedSigner(
|
||||
recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
|
||||
recipientsByRoleToDisplay[0],
|
||||
);
|
||||
}, [recipients]);
|
||||
|
||||
const recipientsByRole = useMemo(() => {
|
||||
@@ -517,6 +531,7 @@ export const AddFieldsFormPartial = ({
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
APPROVER: [],
|
||||
ASSISTANT: [],
|
||||
};
|
||||
|
||||
recipients.forEach((recipient) => {
|
||||
@@ -529,7 +544,12 @@ export const AddFieldsFormPartial = ({
|
||||
const recipientsByRoleToDisplay = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][])
|
||||
.filter(([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER)
|
||||
.filter(
|
||||
([role]) =>
|
||||
role !== RecipientRole.CC &&
|
||||
role !== RecipientRole.VIEWER &&
|
||||
role !== RecipientRole.ASSISTANT,
|
||||
)
|
||||
.map(
|
||||
([role, roleRecipients]) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
@@ -544,12 +564,6 @@ export const AddFieldsFormPartial = ({
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
const isTypedSignatureEnabled = form.watch('typedSignatureEnabled');
|
||||
|
||||
const handleTypedSignatureChange = (value: boolean) => {
|
||||
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const handleAdvancedSettings = () => {
|
||||
setShowAdvancedSettings((prev) => !prev);
|
||||
};
|
||||
@@ -559,7 +573,7 @@ export const AddFieldsFormPartial = ({
|
||||
localFields.some(
|
||||
(field) =>
|
||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||
field.signerEmail === signer.email,
|
||||
field.recipientId === signer.id,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -629,7 +643,7 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
{isDocumentPdfLoaded &&
|
||||
localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
|
||||
const hasFieldError =
|
||||
emptyCheckboxFields.find((f) => f.formId === field.formId) ||
|
||||
emptyRadioFields.find((f) => f.formId === field.formId) ||
|
||||
@@ -641,7 +655,7 @@ export const AddFieldsFormPartial = ({
|
||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||
field={field}
|
||||
disabled={
|
||||
selectedSigner?.email !== field.signerEmail ||
|
||||
selectedSigner?.id !== field.recipientId ||
|
||||
!canRecipientBeModified(selectedSigner, fields)
|
||||
}
|
||||
minHeight={MIN_HEIGHT_PX}
|
||||
@@ -687,9 +701,7 @@ export const AddFieldsFormPartial = ({
|
||||
)}
|
||||
|
||||
{!selectedSigner?.email && (
|
||||
<span className="gradie flex-1 truncate text-left">
|
||||
{selectedSigner?.email}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ZAddFieldsFormSchema = z.object({
|
||||
nativeId: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
recipientId: z.number(),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
|
||||
@@ -13,14 +13,8 @@ import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import {
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
type Field,
|
||||
type Recipient,
|
||||
SendStatus,
|
||||
TeamMemberRole,
|
||||
} from '@documenso/prisma/client';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
|
||||
import {
|
||||
DocumentGlobalAuthAccessSelect,
|
||||
DocumentGlobalAuthAccessTooltip,
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
DocumentFlowFormContainerStep,
|
||||
} from './document-flow-root';
|
||||
import { ShowFieldItem } from './show-field-item';
|
||||
import { SigningOrderConfirmation } from './signing-order-confirmation';
|
||||
import type { DocumentFlowStep } from './types';
|
||||
|
||||
export type AddSignersFormProps = {
|
||||
@@ -123,6 +124,7 @@ export const AddSignersFormPartial = ({
|
||||
}, [recipients, form]);
|
||||
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
|
||||
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
||||
|
||||
const {
|
||||
setValue,
|
||||
@@ -134,6 +136,10 @@ export const AddSignersFormPartial = ({
|
||||
const watchedSigners = watch('signers');
|
||||
const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
|
||||
|
||||
const hasAssistantRole = useMemo(() => {
|
||||
return watchedSigners.some((signer) => signer.role === RecipientRole.ASSISTANT);
|
||||
}, [watchedSigners]);
|
||||
|
||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||
return signers
|
||||
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
|
||||
@@ -233,6 +239,7 @@ export const AddSignersFormPartial = ({
|
||||
const items = Array.from(watchedSigners);
|
||||
const [reorderedSigner] = items.splice(result.source.index, 1);
|
||||
|
||||
// Find next valid position
|
||||
let insertIndex = result.destination.index;
|
||||
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].nativeId)) {
|
||||
insertIndex++;
|
||||
@@ -240,126 +247,116 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
items.splice(insertIndex, 0, reorderedSigner);
|
||||
|
||||
const updatedSigners = items.map((item, index) => ({
|
||||
...item,
|
||||
signingOrder: !canRecipientBeModified(item.nativeId) ? item.signingOrder : index + 1,
|
||||
const updatedSigners = items.map((signer, index) => ({
|
||||
...signer,
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
|
||||
}));
|
||||
|
||||
updatedSigners.forEach((item, index) => {
|
||||
const keys: (keyof typeof item)[] = [
|
||||
'formId',
|
||||
'nativeId',
|
||||
'email',
|
||||
'name',
|
||||
'role',
|
||||
'signingOrder',
|
||||
'actionAuth',
|
||||
];
|
||||
keys.forEach((key) => {
|
||||
form.setValue(`signers.${index}.${key}` as const, item[key]);
|
||||
});
|
||||
});
|
||||
form.setValue('signers', updatedSigners);
|
||||
|
||||
const currentLength = form.getValues('signers').length;
|
||||
if (currentLength > updatedSigners.length) {
|
||||
for (let i = updatedSigners.length; i < currentLength; i++) {
|
||||
form.unregister(`signers.${i}`);
|
||||
}
|
||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
await form.trigger('signers');
|
||||
},
|
||||
[form, canRecipientBeModified, watchedSigners],
|
||||
[form, canRecipientBeModified, watchedSigners, toast],
|
||||
);
|
||||
|
||||
const triggerDragAndDrop = useCallback(
|
||||
(fromIndex: number, toIndex: number) => {
|
||||
if (!$sensorApi.current) {
|
||||
const handleRoleChange = useCallback(
|
||||
(index: number, role: RecipientRole) => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signingOrder = form.getValues('signingOrder');
|
||||
|
||||
// Handle parallel to sequential conversion for assistants
|
||||
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
|
||||
toast({
|
||||
title: _(msg`Signing order is enabled.`),
|
||||
description: _(msg`You cannot add assistants when signing order is disabled.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const draggableId = signers[fromIndex].id;
|
||||
const updatedSigners = currentSigners.map((signer, idx) => ({
|
||||
...signer,
|
||||
role: idx === index ? role : signer.role,
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
|
||||
}));
|
||||
|
||||
const preDrag = $sensorApi.current.tryGetLock(draggableId);
|
||||
form.setValue('signers', updatedSigners);
|
||||
|
||||
if (!preDrag) {
|
||||
return;
|
||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const drag = preDrag.snapLift();
|
||||
|
||||
setTimeout(() => {
|
||||
// Move directly to the target index
|
||||
if (fromIndex < toIndex) {
|
||||
for (let i = fromIndex; i < toIndex; i++) {
|
||||
drag.moveDown();
|
||||
}
|
||||
} else {
|
||||
for (let i = fromIndex; i > toIndex; i--) {
|
||||
drag.moveUp();
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
drag.drop();
|
||||
}, 500);
|
||||
}, 0);
|
||||
},
|
||||
[signers],
|
||||
);
|
||||
|
||||
const updateSigningOrders = useCallback(
|
||||
(newIndex: number, oldIndex: number) => {
|
||||
const updatedSigners = form.getValues('signers').map((signer, index) => {
|
||||
if (index === oldIndex) {
|
||||
return { ...signer, signingOrder: newIndex + 1 };
|
||||
} else if (index >= newIndex && index < oldIndex) {
|
||||
return {
|
||||
...signer,
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId)
|
||||
? signer.signingOrder
|
||||
: (signer.signingOrder ?? index + 1) + 1,
|
||||
};
|
||||
} else if (index <= newIndex && index > oldIndex) {
|
||||
return {
|
||||
...signer,
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId)
|
||||
? signer.signingOrder
|
||||
: Math.max(1, (signer.signingOrder ?? index + 1) - 1),
|
||||
};
|
||||
}
|
||||
return signer;
|
||||
});
|
||||
|
||||
updatedSigners.forEach((signer, index) => {
|
||||
form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
|
||||
});
|
||||
},
|
||||
[form, canRecipientBeModified],
|
||||
[form, toast, canRecipientBeModified],
|
||||
);
|
||||
|
||||
const handleSigningOrderChange = useCallback(
|
||||
(index: number, newOrderString: string) => {
|
||||
const newOrder = parseInt(newOrderString, 10);
|
||||
|
||||
if (!newOrderString.trim()) {
|
||||
const trimmedOrderString = newOrderString.trim();
|
||||
if (!trimmedOrderString) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isNaN(newOrder)) {
|
||||
form.setValue(`signers.${index}.signingOrder`, index + 1);
|
||||
const newOrder = Number(trimmedOrderString);
|
||||
if (!Number.isInteger(newOrder) || newOrder < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = newOrder - 1;
|
||||
if (index !== newIndex) {
|
||||
updateSigningOrders(newIndex, index);
|
||||
triggerDragAndDrop(index, newIndex);
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signer = currentSigners[index];
|
||||
|
||||
// Remove signer from current position and insert at new position
|
||||
const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
|
||||
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
||||
remainingSigners.splice(newPosition, 0, signer);
|
||||
|
||||
const updatedSigners = remainingSigners.map((s, idx) => ({
|
||||
...s,
|
||||
signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
|
||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
[form, triggerDragAndDrop, updateSigningOrders],
|
||||
[form, canRecipientBeModified, toast],
|
||||
);
|
||||
|
||||
const handleSigningOrderDisable = useCallback(() => {
|
||||
setShowSigningOrderConfirmation(false);
|
||||
|
||||
const currentSigners = form.getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
@@ -384,11 +381,16 @@ export const AddSignersFormPartial = ({
|
||||
{...field}
|
||||
id="signingOrder"
|
||||
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
||||
onCheckedChange={(checked) =>
|
||||
onCheckedChange={(checked) => {
|
||||
if (!checked && hasAssistantRole) {
|
||||
setShowSigningOrderConfirmation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(
|
||||
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
disabled={isSubmitting || hasDocumentBeenSent}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -613,7 +615,11 @@ export const AddSignersFormPartial = ({
|
||||
<FormControl>
|
||||
<RecipientRoleSelect
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
isAssistantEnabled={isSigningOrderSequential}
|
||||
onValueChange={(value) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole)
|
||||
}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
@@ -710,6 +716,12 @@ export const AddSignersFormPartial = ({
|
||||
)}
|
||||
</Form>
|
||||
</AnimateGenericFadeInOut>
|
||||
|
||||
<SigningOrderConfirmation
|
||||
open={showSigningOrderConfirmation}
|
||||
onOpenChange={setShowSigningOrderConfirmation}
|
||||
onConfirm={handleSigningOrderDisable}
|
||||
/>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
|
||||
@@ -6,34 +6,22 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-a
|
||||
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
|
||||
import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
|
||||
|
||||
export const ZAddSignersFormSchema = z
|
||||
.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||
ZRecipientActionAuthTypesSchema.optional(),
|
||||
),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
// Dirty hack to handle errors when .root is populated for an array type
|
||||
{ message: msg`Signers must have unique emails`.id, path: ['signers__root'] },
|
||||
);
|
||||
export const ZAddSignersFormSchema = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
});
|
||||
|
||||
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||
|
||||
@@ -59,10 +59,10 @@ export const FieldIcon = ({
|
||||
if (fieldMeta && (type === 'TEXT' || type === 'NUMBER')) {
|
||||
if (type === 'TEXT' && 'text' in fieldMeta && fieldMeta.text && !fieldMeta.label) {
|
||||
label =
|
||||
fieldMeta.text.length > 10 ? fieldMeta.text.substring(0, 10) + '...' : fieldMeta.text;
|
||||
fieldMeta.text.length > 20 ? fieldMeta.text.substring(0, 20) + '...' : fieldMeta.text;
|
||||
} else if (fieldMeta.label) {
|
||||
label =
|
||||
fieldMeta.label.length > 10 ? fieldMeta.label.substring(0, 10) + '...' : fieldMeta.label;
|
||||
fieldMeta.label.length > 20 ? fieldMeta.label.substring(0, 20) + '...' : fieldMeta.label;
|
||||
} else {
|
||||
label = fieldIcons[type]?.label;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@documenso/ui/primitives/alert-dialog';
|
||||
|
||||
export type SigningOrderConfirmationProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function SigningOrderConfirmation({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: SigningOrderConfirmationProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Warning</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have an assistant role on the signers list, removing the signing order will change
|
||||
the assistant role to signer.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Proceed</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -8,19 +8,14 @@ import { FieldType } from '@documenso/prisma/client';
|
||||
export const ZDocumentFlowFormSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
|
||||
signers: z
|
||||
.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
.refine((signers) => {
|
||||
const emails = signers.map((signer) => signer.email);
|
||||
return new Set(emails).size === emails.length;
|
||||
}, 'Signers must have unique emails'),
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
nativeId: z.number().optional(),
|
||||
email: z.string().min(1).email(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
|
||||
fields: z.array(
|
||||
z.object({
|
||||
|
||||
@@ -19,18 +19,18 @@ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, children: _children, ...props }, ref) => {
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-input ring-offset-background focus:ring-ring h-4 w-4 rounded-full border focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'border-primary text-primary focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border shadow focus:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="fill-primary text-primary h-2.5 w-2.5" />
|
||||
<Circle className="fill-primary h-2.5 w-2.5" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadgeCheck, Copy, Eye, PencilLine } from 'lucide-react';
|
||||
import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react';
|
||||
|
||||
import type { RecipientRole } from '.prisma/client';
|
||||
|
||||
@@ -7,4 +7,5 @@ export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
|
||||
APPROVER: <BadgeCheck className="h-4 w-4" />,
|
||||
CC: <Copy className="h-4 w-4" />,
|
||||
VIEWER: <Eye className="h-4 w-4" />,
|
||||
ASSISTANT: <User className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@@ -60,6 +60,7 @@ import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
|
||||
import { Checkbox } from '../checkbox';
|
||||
import type { FieldFormType } from '../document-flow/add-fields';
|
||||
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
|
||||
import { MissingSignatureFieldDialog } from '../document-flow/missing-signature-field-dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form/form';
|
||||
import { useStep } from '../stepper';
|
||||
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||
@@ -110,6 +111,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
const [fieldClipboard, setFieldClipboard] = useState<
|
||||
TAddTemplateFieldsFormSchema['fields'][0] | null
|
||||
>(null);
|
||||
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
|
||||
|
||||
const form = useForm<TAddTemplateFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
@@ -122,6 +124,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageY: Number(field.positionY),
|
||||
pageWidth: Number(field.width),
|
||||
pageHeight: Number(field.height),
|
||||
recipientId: field.recipientId ?? -1,
|
||||
signerId: field.recipientId ?? -1,
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
@@ -177,6 +180,8 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
recipientId:
|
||||
selectedSigner?.id || lastActiveField.recipientId || lastActiveField.signerId || 0,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
@@ -201,19 +206,29 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
event.preventDefault();
|
||||
|
||||
const copiedField = structuredClone(fieldClipboard);
|
||||
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
|
||||
|
||||
append({
|
||||
...copiedField,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? copiedField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? copiedField.signerId,
|
||||
recipientId: selectedSigner?.id || copiedField.recipientId || copiedField.signerId || 0,
|
||||
signerToken: selectedSigner?.token ?? copiedField.signerToken,
|
||||
signerIndex: signerIndex >= 0 ? signerIndex : 0,
|
||||
pageX: copiedField.pageX + 3,
|
||||
pageY: copiedField.pageY + 3,
|
||||
});
|
||||
}
|
||||
},
|
||||
[append, fieldClipboard, selectedSigner?.email, selectedSigner?.id, selectedSigner?.token],
|
||||
[
|
||||
append,
|
||||
fieldClipboard,
|
||||
selectedSigner?.email,
|
||||
selectedSigner?.id,
|
||||
selectedSigner?.token,
|
||||
recipients,
|
||||
],
|
||||
);
|
||||
|
||||
useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt));
|
||||
@@ -319,6 +334,8 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageX -= fieldPageWidth / 2;
|
||||
pageY -= fieldPageHeight / 2;
|
||||
|
||||
const signerIndex = recipients.findIndex((r) => r.id === selectedSigner.id);
|
||||
|
||||
append({
|
||||
formId: nanoid(12),
|
||||
type: selectedField,
|
||||
@@ -329,14 +346,17 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
pageHeight: fieldPageHeight,
|
||||
signerEmail: selectedSigner.email,
|
||||
signerId: selectedSigner.id,
|
||||
recipientId:
|
||||
selectedSigner.id || lastActiveField?.recipientId || lastActiveField?.signerId || 0,
|
||||
signerToken: selectedSigner.token ?? '',
|
||||
signerIndex: signerIndex >= 0 ? signerIndex : 0,
|
||||
fieldMeta: undefined,
|
||||
});
|
||||
|
||||
setIsFieldWithinBounds(false);
|
||||
setSelectedField(null);
|
||||
},
|
||||
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
|
||||
[append, isWithinPageBounds, selectedField, selectedSigner, getPage, recipients],
|
||||
);
|
||||
|
||||
const onFieldResize = useCallback(
|
||||
@@ -438,6 +458,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
APPROVER: [],
|
||||
ASSISTANT: [],
|
||||
};
|
||||
|
||||
recipients.forEach((recipient) => {
|
||||
@@ -447,10 +468,25 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
return recipientsByRole;
|
||||
}, [recipients]);
|
||||
|
||||
useEffect(() => {
|
||||
const recipientsByRoleToDisplay = recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||
);
|
||||
|
||||
setSelectedSigner(
|
||||
recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
|
||||
recipientsByRoleToDisplay[0],
|
||||
);
|
||||
}, [recipients]);
|
||||
|
||||
const recipientsByRoleToDisplay = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
|
||||
([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER,
|
||||
([role]) =>
|
||||
role !== RecipientRole.CC &&
|
||||
role !== RecipientRole.VIEWER &&
|
||||
role !== RecipientRole.ASSISTANT,
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
@@ -483,6 +519,23 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const handleGoNextClick = () => {
|
||||
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
|
||||
localFields.some(
|
||||
(field) =>
|
||||
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
|
||||
field.recipientId === signer.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (!everySignerHasSignature) {
|
||||
setIsMissingSignatureDialogVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
void onFormSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAdvancedSettings && currentField ? (
|
||||
@@ -530,14 +583,15 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
)}
|
||||
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||
const recipientIndex =
|
||||
field.signerIndex ?? recipients.findIndex((r) => r.id === field.signerId);
|
||||
|
||||
return (
|
||||
<FieldItem
|
||||
key={index}
|
||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||
recipientIndex={recipientIndex >= 0 ? recipientIndex : 0}
|
||||
field={field}
|
||||
disabled={selectedSigner?.email !== field.signerEmail}
|
||||
disabled={selectedSigner?.id !== field.signerId}
|
||||
minHeight={MIN_HEIGHT_PX}
|
||||
minWidth={MIN_WIDTH_PX}
|
||||
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||
@@ -977,9 +1031,14 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
previousStep();
|
||||
remove();
|
||||
}}
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
onGoNextClick={handleGoNextClick}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
<MissingSignatureFieldDialog
|
||||
isOpen={isMissingSignatureDialogVisible}
|
||||
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
|
||||
/>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
</>
|
||||
|
||||
@@ -11,12 +11,14 @@ export const ZAddTemplateFieldsFormSchema = z.object({
|
||||
type: z.nativeEnum(FieldType),
|
||||
signerEmail: z.string().min(1),
|
||||
signerToken: z.string(),
|
||||
signerId: z.number().optional(),
|
||||
signerId: z.number(),
|
||||
recipientId: z.number(),
|
||||
pageNumber: z.number().min(1),
|
||||
pageX: z.number().min(0),
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
signerIndex: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user