Compare commits

..

23 Commits

Author SHA1 Message Date
bbf1dd3c6b fix: add tests 2025-11-03 20:30:35 +11:00
c10c95ca00 fix: add tests 2025-11-03 20:17:52 +11:00
4a0425b120 feat: add formdata endpoints for documents,envelopes,templates
Adds the missing endpoints for documents, envelopes and
templates supporting file uploads in a singular request.

Also updates frontend components that would use the prior
hidden endpoints.
2025-11-03 15:11:20 +11:00
a6e923dd8a feat: allow multipart requests for public api
Adds support for multipart/form-data requests in the public api
allowing documents to be uploaded without having to perform a secondary
request.

Need to rollout further endpoints for envelopes and templates.

Need to change how we store files to not use `putFileServerSide`
2025-11-03 15:10:28 +11:00
7e38d06ef5 Merge branch 'main' into feat/add-envelopes-api 2025-11-01 12:47:55 +11:00
d2a009d52e fix: allow direct template recipient dictation (#2108) 2025-11-01 12:44:34 +11:00
4e2443396c fix: increase res 2025-10-31 20:49:57 +11:00
2e2980f04f fix: increase res 2025-10-31 20:28:45 +11:00
3efe0de52f fix: increase threshold 2025-10-31 17:43:33 +11:00
efbd133f0e fix: increase threshold 2025-10-31 17:21:33 +11:00
4993e8a306 fix: test 2025-10-31 17:06:59 +11:00
f93d34c38e fix: clean up endpoints 2025-10-31 15:48:05 +11:00
8c228f965a fix: test 2025-10-31 15:06:20 +11:00
9020bbc753 fix: add regression test 2025-10-31 12:38:14 +11:00
9350c53c7d chore: add code styleguide (#2089)
Co-authored-by: Ephraim Atta-Duncan <ephraimduncan68@gmail.com>
2025-10-28 22:25:27 +11:00
ffce7a2c81 fix: filter document stats by folder (#2083)
This pull request refactors the filtering logic in the `getTeamCounts`
function within `get-stats.ts` to improve consistency and
maintainability. The main change is the consolidation of multiple filter
conditions into a single `AND` clause, which now includes search
filters, folder filters, and visibility filters. This ensures that all
relevant filters are applied in a unified way for document count
queries.
2025-10-28 21:16:12 +11:00
353bdce86b feat: admin member role updates (#2093) 2025-10-28 21:09:38 +11:00
f6bdb34b56 feat: add envelopes api 2025-10-28 20:32:24 +11:00
e13b9f7c84 fix: hide banner for envelope editor (#2109) 2025-10-28 16:55:44 +11:00
9908580bf1 fix: add envelopes flag (#2104)
## Description

Add a global flag override for envelopes
2025-10-28 11:42:03 +11:00
b0b07106b4 fix: envelope autosave (#2103) 2025-10-27 19:53:35 +11:00
35250fa308 feat: server port configurable via PORT env (#2097) 2025-10-27 17:24:24 +11:00
5cdd7f8623 fix: envelope styling (#2102) 2025-10-27 16:11:10 +11:00
190 changed files with 9003 additions and 2005 deletions

View File

@ -29,6 +29,10 @@ NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
# URL used by the web app to request itself (e.g. local background jobs)
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
# [[SERVER]]
# OPTIONAL: The port the server will listen on. Defaults to 3000.
PORT=3000
# [[DATABASE]]
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.

692
CODE_STYLE.md Normal file
View File

@ -0,0 +1,692 @@
# Documenso Code Style Guide
This document captures the code style, patterns, and conventions used in the Documenso codebase. It covers both enforceable rules and subjective "taste" elements that make our code consistent and maintainable.
## Table of Contents
1. [General Principles](#general-principles)
2. [TypeScript Conventions](#typescript-conventions)
3. [Imports & Dependencies](#imports--dependencies)
4. [Functions & Methods](#functions--methods)
5. [React & Components](#react--components)
6. [Error Handling](#error-handling)
7. [Async/Await Patterns](#asyncawait-patterns)
8. [Whitespace & Formatting](#whitespace--formatting)
9. [Naming Conventions](#naming-conventions)
10. [Pattern Matching](#pattern-matching)
11. [Database & Prisma](#database--prisma)
12. [TRPC Patterns](#trpc-patterns)
---
## General Principles
- **Functional over Object-Oriented**: Prefer functional programming patterns over classes
- **Explicit over Implicit**: Be explicit about types, return values, and error cases
- **Early Returns**: Use guard clauses and early returns to reduce nesting
- **Immutability**: Favor `const` over `let`; avoid mutation where possible
---
## TypeScript Conventions
### Type Definitions
```typescript
// ✅ Prefer `type` over `interface`
type CreateDocumentOptions = {
templateId: number;
userId: number;
recipients: Recipient[];
};
// ❌ Avoid interfaces unless absolutely necessary
interface CreateDocumentOptions {
templateId: number;
}
```
### Type Imports
```typescript
// ✅ Use `type` keyword for type-only imports
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
// Types in function signatures
export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => {
// ...
};
```
### Inline Types for Function Parameters
```typescript
// ✅ Extract inline types to named types
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
templateRecipientId: number;
fields: Field[];
};
const finalRecipients: FinalRecipient[] = [];
```
---
## Imports & Dependencies
### Import Organization
Imports should be organized in the following order with blank lines between groups:
```typescript
// 1. React imports
import { useCallback, useEffect, useMemo } from 'react';
// 2. Third-party library imports (alphabetically)
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus, RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
// 3. Internal package imports (from @documenso/*)
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
// 4. Relative imports
import { getTeamById } from '../team/get-team';
import type { FindResultResponse } from './types';
```
### Destructuring Imports
```typescript
// ✅ Destructure specific exports
// ✅ Use type imports for types
import type { Document } from '@prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
```
---
## Functions & Methods
### Arrow Functions
```typescript
// ✅ Always use arrow functions for functions
export const createDocument = async ({
userId,
title,
}: CreateDocumentOptions) => {
// ...
};
// ✅ Callbacks and handlers
const onSubmit = useCallback(async () => {
// ...
}, [dependencies]);
// ❌ Avoid regular function declarations
function createDocument() {
// ...
}
```
### Function Parameters
```typescript
// ✅ Use destructured object parameters for multiple params
export const findDocuments = async ({
userId,
teamId,
status = ExtendedDocumentStatus.ALL,
page = 1,
perPage = 10,
}: FindDocumentsOptions) => {
// ...
};
// ✅ Destructure on separate line when needed
const onFormSubmit = form.handleSubmit(onSubmit);
// ✅ Deconstruct nested properties explicitly
const { user } = ctx;
const { templateId } = input;
```
---
## React & Components
### Component Definition
```typescript
// ✅ Use const with arrow function
export const AddSignersFormPartial = ({
documentFlow,
recipients,
fields,
onSubmit,
}: AddSignersFormProps) => {
// ...
};
// ❌ Never use classes
class MyComponent extends React.Component {
// ...
}
```
### Hooks
```typescript
// ✅ Group related hooks together with blank line separation
const { _ } = useLingui();
const { toast } = useToast();
const { currentStep, totalSteps, previousStep } = useStep();
const form = useForm<TFormSchema>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
// ...
},
});
```
### Event Handlers
```typescript
// ✅ Use arrow functions with descriptive names
const onFormSubmit = async () => {
await form.trigger();
// ...
};
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null) => {
event?.preventDefault();
// ...
},
[dependencies],
);
// ✅ Inline handlers for simple operations
<Button onClick={() => setOpen(false)}>Close</Button>
```
### State Management
```typescript
// ✅ Descriptive state names with auxiliary verbs
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
// ✅ Complex state in single useState when related
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
```
---
## Error Handling
### Try-Catch Blocks
```typescript
// ✅ Use try-catch for operations that might fail
try {
const document = await getDocumentById({
documentId: Number(documentId),
userId: user.id,
});
return {
status: 200,
body: document,
};
} catch (err) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
```
### Throwing Errors
```typescript
// ✅ Use AppError for application errors
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
// ✅ Use descriptive error messages
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Template with ID ${templateId} not found`,
});
}
```
### Error Parsing on Frontend
```typescript
// ✅ Parse errors on the frontend
try {
await updateOrganisation({ organisationId, data });
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description: error.message,
variant: 'destructive',
});
}
```
---
## Async/Await Patterns
### Async Function Definitions
```typescript
// ✅ Mark async functions clearly
export const createDocument = async ({
userId,
title,
}: Options): Promise<Document> => {
// ...
};
// ✅ Use await for promises
const document = await prisma.document.create({ data });
// ✅ Use Promise.all for parallel operations
const [document, recipients] = await Promise.all([
getDocumentById({ documentId }),
getRecipientsForDocument({ documentId }),
]);
```
### Void for Fire-and-Forget
```typescript
// ✅ Use void for intentionally unwaited promises
void handleAutoSave();
// ✅ Or in event handlers
onClick={() => void onFormSubmit()}
```
---
## Whitespace & Formatting
### Blank Lines Between Concepts
```typescript
// ✅ Blank line after imports
import { prisma } from '@documenso/prisma';
export const findDocuments = async () => {
// ...
};
// ✅ Blank line between logical sections
const user = await prisma.user.findFirst({ where: { id: userId } });
let team = null;
if (teamId !== undefined) {
team = await getTeamById({ userId, teamId });
}
// ✅ Blank line before return statements
const result = await someOperation();
return result;
```
### Function/Method Spacing
```typescript
// ✅ No blank lines between chained methods in same operation
const documents = await prisma.document
.findMany({ where: { userId } })
.then((docs) => docs.map(maskTokens));
// ✅ Blank line between different operations
const document = await createDocument({ userId });
await sendDocument({ documentId: document.id });
return document;
```
### Object and Array Formatting
```typescript
// ✅ Multi-line when complex
const options = {
userId,
teamId,
status: ExtendedDocumentStatus.ALL,
page: 1,
};
// ✅ Single line when simple
const coords = { x: 0, y: 0 };
// ✅ Array items on separate lines when objects
const recipients = [
{
name: 'John',
email: 'john@example.com',
},
{
name: 'Jane',
email: 'jane@example.com',
},
];
```
---
## Naming Conventions
### Variables
```typescript
// ✅ camelCase for variables and functions
const documentId = 123;
const onSubmit = () => {};
// ✅ Descriptive names with auxiliary verbs for booleans
const isLoading = false;
const hasError = false;
const canEdit = true;
const shouldRender = true;
// ✅ Prefix with $ for DOM elements
const $page = document.querySelector('.page');
const $inputRef = useRef<HTMLInputElement>(null);
```
### Types and Schemas
```typescript
// ✅ PascalCase for types
type CreateDocumentOptions = {
userId: number;
};
// ✅ Prefix Zod schemas with Z
const ZCreateDocumentSchema = z.object({
title: z.string(),
});
// ✅ Prefix type from Zod schema with T
type TCreateDocumentSchema = z.infer<typeof ZCreateDocumentSchema>;
```
### Constants
```typescript
// ✅ UPPER_SNAKE_CASE for true constants
const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy';
const MAX_FILE_SIZE = 1024 * 1024 * 5;
// ✅ camelCase for const variables that aren't "constants"
const userId = await getUserId();
```
### Functions
```typescript
// ✅ Verb-based names for functions
const createDocument = async () => {};
const findDocuments = async () => {};
const updateDocument = async () => {};
const deleteDocument = async () => {};
// ✅ On prefix for event handlers
const onSubmit = () => {};
const onClick = () => {};
const onFieldCopy = () => {}; // 'on' is also acceptable
```
### Clarity Over Brevity
```typescript
// ✅ Prefer descriptive names over abbreviations
const superLongMethodThatIsCorrect = () => {};
const recipientAuthenticationOptions = {};
const documentMetadata = {};
// ❌ Avoid abbreviations that sacrifice clarity
const supLongMethThatIsCorrect = () => {};
const recipAuthOpts = {};
const docMeta = {};
// ✅ Common abbreviations that are widely understood are acceptable
const userId = 123;
const htmlElement = document.querySelector('div');
const apiResponse = await fetch('/api');
```
---
## Pattern Matching
### Using ts-pattern
```typescript
import { match } from 'ts-pattern';
// ✅ Use match for complex conditionals
const result = match(status)
.with(ExtendedDocumentStatus.DRAFT, () => ({
status: 'draft',
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
status: 'pending',
}))
.with(ExtendedDocumentStatus.COMPLETED, () => ({
status: 'completed',
}))
.exhaustive();
// ✅ Use .otherwise() for default case when not exhaustive
const value = match(type)
.with('text', () => 'Text field')
.with('number', () => 'Number field')
.otherwise(() => 'Unknown field');
```
---
## Database & Prisma
### Query Structure
```typescript
// ✅ Destructure commonly used fields
const { id, email, name } = user;
// ✅ Use select to limit returned fields
const user = await prisma.user.findFirst({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
},
});
// ✅ Use include for relations
const document = await prisma.document.findFirst({
where: { id: documentId },
include: {
recipients: true,
fields: true,
},
});
```
### Transactions
```typescript
// ✅ Use transactions for related operations
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({ data });
await tx.field.createMany({ data: fieldsData });
await tx.documentAuditLog.create({ data: auditData });
return document;
});
```
### Where Clauses
```typescript
// ✅ Build complex where clauses separately
const whereClause: Prisma.DocumentWhereInput = {
AND: [
{ userId: user.id },
{ deletedAt: null },
{ status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } },
],
};
const documents = await prisma.document.findMany({
where: whereClause,
});
```
---
## TRPC Patterns
### Router Structure
```typescript
// ✅ Destructure context and input at start
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const { templateId } = input;
ctx.logger.info({
input: { templateId },
});
return await getTemplateById({
id: templateId,
userId: ctx.user.id,
teamId,
});
});
```
### Request/Response Schemas
```typescript
// ✅ Name schemas clearly
const ZCreateDocumentRequestSchema = z.object({
title: z.string(),
recipients: z.array(ZRecipientSchema),
});
const ZCreateDocumentResponseSchema = z.object({
documentId: z.number(),
status: z.string(),
});
```
### Error Handling in TRPC
```typescript
// ✅ Catch and transform errors appropriately
try {
const result = await createDocument({ userId, data });
return result;
} catch (err) {
return AppError.toRestAPIError(err);
}
// ✅ Or throw AppError directly
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
```
---
## Additional Patterns
### Optional Chaining
```typescript
// ✅ Use optional chaining for potentially undefined values
const email = user?.email;
const recipientToken = recipient?.token ?? '';
// ✅ Use nullish coalescing for defaults
const pageSize = perPage ?? 10;
const status = documentStatus ?? DocumentStatus.DRAFT;
```
### Array Operations
```typescript
// ✅ Use functional array methods
const activeRecipients = recipients.filter((r) => r.signingStatus === 'SIGNED');
const recipientEmails = recipients.map((r) => r.email);
const hasSignedRecipients = recipients.some((r) => r.signingStatus === 'SIGNED');
// ✅ Use find instead of filter + [0]
const recipient = recipients.find((r) => r.id === recipientId);
```
### Conditional Rendering
```typescript
// ✅ Use && for conditional rendering
{isLoading && <Loader />}
// ✅ Use ternary for either/or
{isLoading ? <Loader /> : <Content />}
// ✅ Extract complex conditions to variables
const shouldShowAdvanced = isAdmin && hasPermission && !isDisabled;
{shouldShowAdvanced && <AdvancedSettings />}
```
---
## When in Doubt
- **Consistency**: Follow the patterns you see in similar files
- **Readability**: Favor code that's easy to read over clever one-liners
- **Explicitness**: Be explicit rather than implicit
- **Whitespace**: Use blank lines to separate logical sections
- **Early Returns**: Use guard clauses to reduce nesting
- **Functional**: Prefer functional patterns over imperative ones

View File

@ -5,7 +5,7 @@
"scripts": {
"dev": "next dev -p 3003",
"build": "next build",
"start": "next start",
"start": "next start -p 3003",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules"
},

View File

@ -0,0 +1,218 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminOrganisationMemberUpdateDialogProps = {
trigger?: React.ReactNode;
organisationId: string;
organisationMember: TGetAdminOrganisationResponse['members'][number];
isOwner: boolean;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateOrganisationMemberFormSchema = z.object({
role: z.enum(['OWNER', 'ADMIN', 'MANAGER', 'MEMBER']),
});
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
export const AdminOrganisationMemberUpdateDialog = ({
trigger,
organisationId,
organisationMember,
isOwner,
...props
}: AdminOrganisationMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
// Determine the current role value for the form
const currentRoleValue = isOwner
? 'OWNER'
: getHighestOrganisationRoleInGroup(
organisationMember.organisationGroupMembers.map((ogm) => ogm.group),
);
const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email;
const form = useForm<ZUpdateOrganisationMemberSchema>({
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
defaultValues: {
role: currentRoleValue,
},
});
const { mutateAsync: updateOrganisationMemberRole } =
trpc.admin.organisationMember.updateRole.useMutation();
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
try {
await updateOrganisationMemberRole({
organisationId,
userId: organisationMember.userId,
role,
});
const roleLabel = match(role)
.with('OWNER', () => t`Owner`)
.with(OrganisationMemberRole.ADMIN, () => t`Admin`)
.with(OrganisationMemberRole.MANAGER, () => t`Manager`)
.with(OrganisationMemberRole.MEMBER, () => t`Member`)
.exhaustive();
toast({
title: t`Success`,
description:
role === 'OWNER'
? t`Ownership transferred to ${organisationMemberName}.`
: t`Updated ${organisationMemberName} to ${roleLabel}.`,
duration: 5000,
});
setOpen(false);
// Refresh the page to show updated data
await navigate(0);
} catch (err) {
console.error(err);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset({
role: currentRoleValue,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRoleValue, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Update role</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update organisation member</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationMemberName}.</span>
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-full" position="popper">
<SelectItem value="OWNER">
<Trans>Owner</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.ADMIN}>
<Trans>Admin</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.MANAGER}>
<Trans>Manager</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.MEMBER}>
<Trans>Member</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -15,18 +15,16 @@ import {
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -65,6 +63,7 @@ export type EnvelopeDistributeDialogProps = {
fields: Pick<Field, 'type' | 'recipientId'>[];
};
onDistribute?: () => Promise<void>;
documentRootPath: string;
trigger?: React.ReactNode;
};
@ -89,6 +88,7 @@ export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFo
export const EnvelopeDistributeDialog = ({
envelope,
trigger,
documentRootPath,
onDistribute,
}: EnvelopeDistributeDialogProps) => {
const organisation = useCurrentOrganisation();
@ -97,6 +97,7 @@ export const EnvelopeDistributeDialog = ({
const { toast } = useToast();
const { t } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
@ -163,6 +164,14 @@ export const EnvelopeDistributeDialog = ({
await onDistribute?.();
let redirectPath = `${documentRootPath}/${envelope.id}`;
if (meta.distributionMethod === DocumentDistributionMethod.NONE) {
redirectPath += '?action=copy-links';
}
await navigate(redirectPath);
toast({
title: t`Envelope distributed`,
description: t`Your envelope has been distributed successfully.`,
@ -198,6 +207,7 @@ export const EnvelopeDistributeDialog = ({
<Trans>Recipients will be able to sign the document once sent</Trans>
</DialogDescription>
</DialogHeader>
{!invalidEnvelopeCode ? (
<Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}>
@ -220,7 +230,11 @@ export const EnvelopeDistributeDialog = ({
</TabsList>
</Tabs>
<div className="min-h-72">
<div
className={cn('min-h-72', {
'min-h-[23rem]': organisation.organisationClaim.flags.emailDomains,
})}
>
<AnimatePresence initial={false} mode="wait">
{distributionMethod === DocumentDistributionMethod.EMAIL && (
<motion.div
@ -355,73 +369,18 @@ export const EnvelopeDistributeDialog = ({
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="min-h-60 rounded-lg border"
>
{envelope.status === DocumentStatus.DRAFT ? (
<div className="text-muted-foreground py-24 text-center text-sm">
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<div className="text-muted-foreground py-24 text-center text-sm">
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send to the
recipients through your method of choice.
</Trans>
</p>
</div>
) : (
<ul className="text-muted-foreground divide-y">
{/* Todo: Envelopes - I don't think this section shows up */}
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li
key={recipient.id}
className="flex items-center justify-between px-4 py-3 text-sm"
>
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={
<p className="text-muted-foreground text-sm">
{recipient.email}
</p>
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{t(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
{recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: t`Copied to clipboard`,
description: t`The signing link has been copied to your clipboard.`,
});
}}
badgeContentUncopied={
<p className="ml-1 text-xs">
<Trans>Copy</Trans>
</p>
}
badgeContentCopied={
<p className="ml-1 text-xs">
<Trans>Copied</Trans>
</p>
}
/>
)}
</li>
))}
</ul>
)}
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send to the
recipients through your method of choice.
</Trans>
</p>
</div>
</motion.div>
)}
</AnimatePresence>

View File

@ -213,8 +213,6 @@ export const EnvelopeDownloadDialog = ({
</div>
))
)}
{/* Todo: Envelopes - Download all button */}
</div>
</DialogContent>
</Dialog>

View File

@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -54,13 +54,17 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true);
try {
const response = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
const payload = {
title: file.name,
templateDocumentDataId: response.id,
folderId: folderId,
});
} satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({
title: _(msg`Template document uploaded`),

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
@ -60,7 +61,12 @@ export const EditorFieldSignatureForm = ({
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
<div>
<EditorGenericFontSizeField formControl={form.control} />
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>The typed signature font size</Trans>
</p>
</div>
</fieldset>
</form>
</Form>

View File

@ -89,7 +89,10 @@ export const DirectTemplatePageView = ({
setStep('sign');
};
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
const onSignDirectTemplateSubmit = async (
fields: DirectTemplateLocalField[],
nextSigner?: { name: string; email: string },
) => {
try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
@ -98,6 +101,7 @@ export const DirectTemplatePageView = ({
}
const { token } = await createDocumentFromDirectTemplate({
nextSigner,
directTemplateToken,
directTemplateExternalId,
directRecipientName: fullName,

View File

@ -55,10 +55,13 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
export type DirectTemplateSigningFormProps = {
flowStep: DocumentFlowStep;
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>;
directRecipientFields: Field[];
template: Omit<TTemplate, 'user'>;
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
onSubmit: (
_data: DirectTemplateLocalField[],
_nextSigner?: { name: string; email: string },
) => Promise<void>;
};
export type DirectTemplateLocalField = Field & {
@ -149,7 +152,7 @@ export const DirectTemplateSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const handleSubmit = async () => {
const handleSubmit = async (nextSigner?: { name: string; email: string }) => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
@ -161,7 +164,7 @@ export const DirectTemplateSigningForm = ({
setIsSubmitting(true);
try {
await onSubmit(localFields);
await onSubmit(localFields, nextSigner);
} catch {
setIsSubmitting(false);
}
@ -218,6 +221,30 @@ export const DirectTemplateSigningForm = ({
setLocalFields(updatedFields);
}, []);
const nextRecipient = useMemo(() => {
if (
!template.templateMeta?.signingOrder ||
template.templateMeta.signingOrder !== 'SEQUENTIAL' ||
!template.templateMeta.allowDictateNextSigner
) {
return undefined;
}
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return (
<DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
@ -417,11 +444,15 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={async () => handleSubmit()}
onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)}
documentTitle={template.title}
fields={localFields}
fieldsValidated={fieldsValidated}
recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/>
</div>
</DocumentFlowFormContainerFooter>

View File

@ -8,11 +8,13 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive
export type DocumentSigningAttachmentsPopoverProps = {
envelopeId: string;
token: string;
trigger?: React.ReactNode;
};
export const DocumentSigningAttachmentsPopover = ({
envelopeId,
token,
trigger,
}: DocumentSigningAttachmentsPopoverProps) => {
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
@ -26,15 +28,17 @@ export const DocumentSigningAttachmentsPopover = ({
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<PaperclipIcon className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>{' '}
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
{trigger ?? (
<Button variant="outline" className="gap-2">
<PaperclipIcon className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>{' '}
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
)}
</PopoverTrigger>
<PopoverContent className="w-96" align="start">

View File

@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentSigningAuthPageViewProps = {
email: string;
email?: string;
emailHasAccount?: boolean;
};
@ -22,12 +22,18 @@ export const DocumentSigningAuthPageView = ({
const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async (email: string) => {
const handleChangeAccount = async (email?: string) => {
try {
setIsSigningOut(true);
let redirectPath = '/signin';
if (email) {
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
}
await authClient.signOut({
redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`,
redirectPath,
});
} catch {
toast({
@ -49,9 +55,13 @@ export const DocumentSigningAuthPageView = ({
</h1>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
You need to be logged in as <strong>{email}</strong> to view this page.
</Trans>
{email ? (
<Trans>
You need to be logged in as <strong>{email}</strong> to view this page.
</Trans>
) : (
<Trans>You need to be logged in to view this page.</Trans>
)}
</p>
<Button

View File

@ -24,7 +24,10 @@ type PasskeyData = {
isError: boolean;
};
type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
type SigningAuthRecipient = Pick<
Recipient,
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
>;
export type DocumentSigningAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;

View File

@ -304,7 +304,6 @@ export const DocumentSigningCompleteDialog = ({
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && (
<div className="mb-4 flex flex-col gap-4">
{/* Todo: Envelopes - Should we say "The next recipient to sign this document will be"? */}
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}

View File

@ -3,7 +3,7 @@ import { lazy, useMemo } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon } from 'lucide-react';
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@ -75,7 +75,7 @@ export const DocumentSigningPageViewV2 = () => {
<EnvelopeSignerHeader />
{/* Main Content Area */}
<div className="flex h-[calc(100vh-73px)] w-screen">
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
<div className="px-4">
@ -121,12 +121,16 @@ export const DocumentSigningPageViewV2 = () => {
<Trans>Actions</Trans>
</h4>
<div className="w-full">
<DocumentSigningAttachmentsPopover
envelopeId={envelope.id}
token={recipient.token}
/>
</div>
<DocumentSigningAttachmentsPopover
envelopeId={envelope.id}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<PaperclipIcon className="mr-2 h-4 w-4" />
<Trans>Attachments</Trans>
</Button>
}
/>
<EnvelopeDownloadDialog
envelopeId={envelope.id}

View File

@ -8,6 +8,7 @@ import {
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { prop, sortBy } from 'remeda';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@ -165,7 +166,29 @@ export const EnvelopeSigningProvider = ({
* The fields that are still required to be signed by the actual recipient.
*/
const recipientFieldsRemaining = useMemo(() => {
return envelopeData.recipient.fields.filter((field) => isFieldUnsignedAndRequired(field));
const requiredFields = envelopeData.recipient.fields
.filter((field) => isFieldUnsignedAndRequired(field))
.map((field) => {
const envelopeItem = envelope.envelopeItems.find(
(item) => item.id === field.envelopeItemId,
);
if (!envelopeItem) {
throw new Error('Missing envelope item');
}
return {
...field,
envelopeItemOrder: envelopeItem.order,
};
});
return sortBy(
requiredFields,
[prop('envelopeItemOrder'), 'asc'],
[prop('page'), 'asc'],
[prop('positionY'), 'asc'],
);
}, [envelopeData.recipient.fields]);
/**
@ -262,8 +285,6 @@ export const EnvelopeSigningProvider = ({
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
console.log('insertField', fieldId, fieldValue);
// Set the field locally for direct templates.
if (isDirectTemplate) {
handleDirectTemplateFieldInsertion(fieldId, fieldValue);

View File

@ -4,7 +4,10 @@ import { Trans } from '@lingui/react/macro';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { DateTime } from 'luxon';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
@ -92,6 +95,60 @@ export const DocumentCertificateQRView = ({
</Dialog>
)}
{internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<DocumentCertificateQrV2
title={title}
recipientCount={recipientCount}
formattedDate={formattedDate}
/>
</EnvelopeRenderProvider>
) : (
<>
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1>
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
<p>
<Trans>{recipientCount} recipients</Trans>
</p>
<p>
<Trans>Completed on {formattedDate}</Trans>
</p>
</div>
</div>
<ShareDocumentDownloadButton
title={title}
documentData={envelopeItems[0].documentData}
/>
</div>
<div className="mt-12 w-full">
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</div>
</>
)}
</div>
);
};
type DocumentCertificateQrV2Props = {
title: string;
recipientCount: number;
formattedDate: string;
};
const DocumentCertificateQrV2 = ({
title,
recipientCount,
formattedDate,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1>
@ -106,21 +163,18 @@ export const DocumentCertificateQRView = ({
</div>
</div>
<ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} />
{currentEnvelopeItem && (
<ShareDocumentDownloadButton
title={title}
documentData={currentEnvelopeItem.documentData}
/>
)}
</div>
<div className="mt-12 w-full">
{internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</EnvelopeRenderProvider>
) : (
<>
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</>
)}
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</div>
</div>
);

View File

@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
try {
setIsLoading(true);
const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
const payload = {
title: file.name,
documentDataId: response.id,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
timezone: userTimezone,
folderId: folderId ?? undefined,
});
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits();

View File

@ -1,7 +1,10 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import {
AlertTriangle,
CheckIcon,
@ -12,7 +15,7 @@ import {
PlusIcon,
UserIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@ -24,6 +27,12 @@ import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Badge } from '@documenso/ui/primitives/badge';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewRecipientsProps = {
@ -37,8 +46,24 @@ export const DocumentPageViewRecipients = ({
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const recipients = envelope.recipients;
const [shouldHighlightCopyButtons, setShouldHighlightCopyButtons] = useState(false);
// Check for action=view-tokens query parameter and set highlighting state
useEffect(() => {
const hasViewTokensAction = searchParams.get('action') === 'copy-links';
if (hasViewTokensAction) {
setShouldHighlightCopyButtons(true);
// Remove the query parameter immediately
const params = new URLSearchParams(searchParams);
params.delete('action');
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
@ -69,7 +94,7 @@ export const DocumentPageViewRecipients = ({
</li>
)}
{recipients.map((recipient) => (
{recipients.map((recipient, i) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
@ -159,15 +184,33 @@ export const DocumentPageViewRecipients = ({
{envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
}}
/>
<TooltipProvider>
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
<TooltipTrigger asChild>
<div
className={shouldHighlightCopyButtons ? 'animate-pulse' : ''}
onClick={() => setShouldHighlightCopyButtons(false)}
>
<CopyTextButton
value={formatSigningLink(recipient.token)}
onCopySuccess={() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(
msg`The signing link has been copied to your clipboard.`,
),
});
setShouldHighlightCopyButtons(false);
}}
/>
</div>
</TooltipTrigger>
<TooltipContent sideOffset={2}>
<Trans>Copy Signing Links</Trans>
<TooltipArrow className="fill-background" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</li>

View File

@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import {
@ -73,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
try {
setIsLoading(true);
const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
const payload = {
title: file.name,
documentDataId: response.id,
timezone: userTimezone,
folderId: folderId ?? undefined,
});
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits();

View File

@ -14,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import {
@ -78,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try {
setIsLoading(true);
const result = await Promise.all(
files.map(async (file) => {
try {
const response = await putPdfFile(file);
return {
title: file.name,
documentDataId: response.id,
};
} catch (err) {
console.error(err);
throw new Error('Failed to upload document');
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { id } = await createEnvelope({
const payload = {
folderId,
type,
title: files[0].name,
items: envelopeItemsToCreate,
meta: {
timezone: userTimezone,
},
}).catch((error) => {
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEnvelope(formData).catch((error) => {
console.error(error);
throw error;

View File

@ -57,8 +57,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
console.log('Field resized or moved');
const { current: container } = canvasElement;
if (!container) {
@ -273,9 +271,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
return;
}
console.log(`pointerPosition.x: ${pointerPosition.x}`);
console.log(`pointerPosition.y: ${pointerPosition.y}`);
x1 = pointerPosition.x / scale;
y1 = pointerPosition.y / scale;
x2 = pointerPosition.x / scale;

View File

@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { Link } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@ -20,6 +21,7 @@ import type {
TNameFieldMeta,
TNumberFieldMeta,
TRadioFieldMeta,
TSignatureFieldMeta,
TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
@ -37,6 +39,7 @@ import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
@ -61,7 +64,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
};
export const EnvelopeEditorFieldsPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -104,12 +107,12 @@ export const EnvelopeEditorFieldsPage = () => {
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col">
<div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex justify-center p-4">
<div className="mt-4 flex h-full justify-center p-4">
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
) : (
@ -128,7 +131,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && (
<div className="bg-background border-border sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l py-4">
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="text-foreground mb-2 text-sm font-semibold">
@ -137,8 +140,14 @@ export const EnvelopeEditorFieldsPage = () => {
{envelope.recipients.length === 0 ? (
<Alert variant="warning">
<AlertDescription>
<AlertDescription className="flex flex-col gap-2">
<Trans>You need at least one recipient to add fields</Trans>
<Link to={`${relativePath.editorPath}`} className="text-sm">
<p>
<Trans>Click here to add a recipient</Trans>
</p>
</Link>
</AlertDescription>
</Alert>
) : (
@ -182,7 +191,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && selectedField.type !== FieldType.SIGNATURE && (
{selectedField && (
<section>
<Separator className="my-4" />
@ -192,6 +201,12 @@ export const EnvelopeEditorFieldsPage = () => {
</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}

View File

@ -37,7 +37,6 @@ export default function EnvelopeEditorHeader() {
updateEnvelope,
autosaveError,
relativePath,
syncEnvelope,
editorFields,
} = useCurrentEnvelopeEditor();
@ -152,7 +151,7 @@ export default function EnvelopeEditorHeader() {
...envelope,
fields: editorFields.localFields,
}}
onDistribute={syncEnvelope}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />

View File

@ -33,7 +33,7 @@ export const EnvelopeEditorPreviewPage = () => {
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col">
<div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
@ -82,7 +82,7 @@ export const EnvelopeEditorPreviewPage = () => {
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && false && (
<div className="sticky top-0 h-[calc(100vh-73px)] w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
{/* Add fields section. */}
<section className="px-4">
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">

View File

@ -14,7 +14,7 @@ import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@ -148,8 +148,7 @@ export const EnvelopeEditorRecipientForm = () => {
},
});
// Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => {
const recipientHasAuthSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
@ -165,7 +164,7 @@ export const EnvelopeEditorRecipientForm = () => {
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(recipientHasAuthSettings);
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
const {
@ -464,7 +463,7 @@ export const EnvelopeEditorRecipientForm = () => {
const formValueSigners = formValues.signers || [];
// Remove the last signer if it's empty.
const recipients = formValueSigners.filter((signer, i) => {
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
if (i === formValueSigners.length - 1 && signer.email === '') {
return false;
}
@ -474,26 +473,48 @@ export const EnvelopeEditorRecipientForm = () => {
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
...formValues,
signers: recipients,
signers: nonEmptyRecipients,
});
if (validatedFormValues.success) {
console.log('validatedFormValues', validatedFormValues);
if (!validatedFormValues.success) {
return;
}
const { data } = validatedFormValues;
const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
const hasAllowDictateNextSignerChanged =
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
const hasSignersChanged =
data.signers.length !== recipients.length ||
data.signers.some((signer) => {
const recipient = recipients.find((recipient) => recipient.id === signer.id);
if (!recipient) {
return true;
}
return (
signer.email !== recipient.email ||
signer.name !== recipient.name ||
signer.role !== recipient.role ||
signer.signingOrder !== recipient.signingOrder ||
!isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth)
);
});
if (hasSignersChanged) {
setRecipientsDebounced(validatedFormValues.data.signers);
}
if (
validatedFormValues.data.signingOrder !== envelope.documentMeta.signingOrder ||
validatedFormValues.data.allowDictateNextSigner !==
envelope.documentMeta.allowDictateNextSigner
) {
updateEnvelope({
meta: {
signingOrder: validatedFormValues.data.signingOrder,
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
},
});
}
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
updateEnvelope({
meta: {
signingOrder: validatedFormValues.data.signingOrder,
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
},
});
}
}, [formValues]);
@ -534,17 +555,16 @@ export const EnvelopeEditorRecipientForm = () => {
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
{!alwaysShowAdvancedSettings && organisation.organisationClaim.flags.cfr21 && (
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
className="h-5 w-5"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="text-muted-foreground ml-2 text-sm"
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor="showAdvancedRecipientSettings"
>
<Trans>Show advanced settings</Trans>
@ -703,171 +723,48 @@ export const EnvelopeEditorRecipientForm = () => {
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('grid grid-cols-10 items-end gap-2 pb-2', {
'border-b pt-2': showAdvancedSettings,
'grid-cols-12 pr-3': isSigningOrderSequential,
className={cn('pb-2', {
'border-b pb-4':
showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn(
'col-span-1 mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn({
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'col-span-10': isSigningOrderSequential,
})}
className={cn(
'mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<RecipientActionAuthSelect
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onValueChange={field.onChange}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
@ -875,20 +772,109 @@ export const EnvelopeEditorRecipientForm = () => {
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2 flex gap-x-2">
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto', {
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
@ -916,14 +902,11 @@ export const EnvelopeEditorRecipientForm = () => {
)}
/>
<button
type="button"
className={cn(
'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
{
'mb-6': form.formState.errors.signers?.[index],
},
)}
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
@ -934,8 +917,40 @@ export const EnvelopeEditorRecipientForm = () => {
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</button>
</Button>
</div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}

View File

@ -242,7 +242,6 @@ export const EnvelopeEditorSettingsDialog = ({
try {
await updateEnvelope({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: {
externalId: data.externalId || null,
visibility: data.visibility,
@ -355,7 +354,7 @@ export const EnvelopeEditorSettingsDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex min-h-[45rem] w-full flex-col space-y-6 px-6 pt-6"
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6"
disabled={form.formState.isSubmitting}
key={activeTab}
>

View File

@ -142,7 +142,7 @@ export const EnvelopeEditorUploadPage = () => {
const { createdEnvelopeItems } = await createEnvelopeItems({
envelopeId: envelope.id,
items: envelopeItemsToCreate,
data: envelopeItemsToCreate,
}).catch((error) => {
console.error(error);

View File

@ -81,7 +81,6 @@ export default function EnvelopeEditor() {
isAutosaving,
flushAutosave,
relativePath,
syncEnvelope,
editorFields,
} = useCurrentEnvelopeEditor();
@ -157,7 +156,7 @@ export default function EnvelopeEditor() {
<EnvelopeEditorHeader />
{/* Main Content Area */}
<div className="flex h-[calc(100vh-73px)] w-screen">
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
{/* Left section step selector. */}
@ -251,7 +250,7 @@ export default function EnvelopeEditor() {
...envelope,
fields: editorFields.localFields,
}}
onDistribute={syncEnvelope}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
@ -369,16 +368,14 @@ export default function EnvelopeEditor() {
</div>
{/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto">
<AnimateGenericFadeInOut key={currentStep}>
{match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
</AnimateGenericFadeInOut>
</div>
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
{match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
</AnimateGenericFadeInOut>
</div>
</div>
);

View File

@ -20,7 +20,8 @@ export const EnvelopeItemSelector = ({
}: EnvelopeItemSelectorProps) => {
return (
<button
className={`flex min-w-0 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
title={typeof primaryText === 'string' ? primaryText : undefined}
className={`flex h-fit max-w-72 flex-shrink-0 cursor-pointer items-center space-x-3 rounded-lg border px-4 py-3 transition-colors ${
isSelected
? 'border-green-200 bg-green-50 text-green-900 dark:border-green-400/30 dark:bg-green-400/10 dark:text-green-400'
: 'border-border bg-muted/50 hover:bg-muted/70'
@ -39,7 +40,7 @@ export const EnvelopeItemSelector = ({
<div className="text-xs text-gray-500">{secondaryText}</div>
</div>
<div
className={cn('h-2 w-2 rounded-full', {
className={cn('h-2 w-2 flex-shrink-0 rounded-full', {
'bg-green-500': isSelected,
})}
></div>
@ -61,7 +62,7 @@ export const EnvelopeRendererFileSelector = ({
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
return (
<div className={cn('flex h-fit space-x-2 overflow-x-auto p-4', className)}>
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
{envelopeItems.map((doc, i) => (
<EnvelopeItemSelector
key={doc.id}

View File

@ -12,7 +12,7 @@ import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
export default function EnvelopeGenericPageRenderer() {
const { i18n } = useLingui();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender();
const {
stage,
@ -60,8 +60,7 @@ export default function EnvelopeGenericPageRenderer() {
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
// color: getRecipientColorKey(field.recipientId),
color: 'purple', // Todo
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: 'sign',
});
@ -80,7 +79,7 @@ export default function EnvelopeGenericPageRenderer() {
};
/**
* Render fields when they are added or removed from the localFields.
* Render fields when they are added or removed
*/
useEffect(() => {
if (!pageLayer.current || !stage.current) {
@ -93,14 +92,12 @@ export default function EnvelopeGenericPageRenderer() {
group.name() === 'field-group' &&
!localPageFields.some((field) => field.id.toString() === group.id())
) {
console.log('Field removed, removing from canvas');
group.destroy();
}
});
// If it exists, rerender.
localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field);
});

View File

@ -127,6 +127,7 @@ export const EnvelopeSignerCompleteDialog = () => {
isBase64,
};
}),
nextSigner,
});
const redirectUrl = envelope.documentMeta.redirectUrl;

View File

@ -6,6 +6,7 @@ import { FolderIcon, HomeIcon } from 'lucide-react';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_ENVELOPES_ENABLED } from '@documenso/lib/constants/app';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
@ -98,7 +99,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
</div>
<div className="flex gap-4 sm:flex-row sm:justify-end">
{organisation.organisationClaim.flags.allowEnvelopes && (
{(IS_ENVELOPES_ENABLED || organisation.organisationClaim.flags.allowEnvelopes) && (
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
)}

View File

@ -15,7 +15,6 @@ export type ShareDocumentDownloadButtonProps = {
documentData: DocumentData;
};
// Todo: Envelopes - Support multiple item downloads.
export const ShareDocumentDownloadButton = ({
title,
documentData,

View File

@ -10,9 +10,9 @@ import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -40,13 +40,17 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
try {
setIsLoading(true);
const documentData = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
const payload = {
title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined,
});
} satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({
title: _(msg`Template uploaded`),

View File

@ -116,7 +116,7 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && <AppBanner banner={banner} />}
{banner && !hideHeader && <AppBanner banner={banner} />}
{!hideHeader && <Header />}

View File

@ -34,6 +34,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
@ -71,23 +72,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
});
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
trpc.admin.organisationMember.promoteToOwner.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Member promoted to owner successfully`,
});
},
onError: () => {
toast({
title: t`Error`,
description: t`We couldn't promote the member to owner. Please try again.`,
variant: 'destructive',
});
},
});
const teamsColumns = useMemo(() => {
return [
{
@ -120,23 +104,24 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
{
header: t`Actions`,
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button
variant="outline"
disabled={row.original.userId === organisation?.ownerUserId}
loading={isPromotingToOwner}
onClick={async () =>
promoteToOwner({
organisationId,
userId: row.original.userId,
})
}
>
<Trans>Promote to owner</Trans>
</Button>
</div>
),
cell: ({ row }) => {
const isOwner = row.original.userId === organisation?.ownerUserId;
return (
<div className="flex justify-end space-x-2">
<AdminOrganisationMemberUpdateDialog
trigger={
<Button variant="outline">
<Trans>Update role</Trans>
</Button>
}
organisationId={organisationId}
organisationMember={row.original}
isOwner={isOwner}
/>
</div>
);
},
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]);

View File

@ -148,6 +148,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<EnvelopeRenderProvider
envelope={envelope}
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />

View File

@ -99,7 +99,11 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return (
<EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider envelope={envelope}>
<EnvelopeRenderProvider
envelope={envelope}
fields={envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
<EnvelopeEditor />
</EnvelopeRenderProvider>
</EnvelopeEditorProvider>

View File

@ -168,7 +168,11 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<div className="mt-6 grid w-full grid-cols-12 gap-8">
{envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider envelope={envelope} fields={envelope.fields}>
<EnvelopeRenderProvider
envelope={envelope}
fields={envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
)}

View File

@ -8,7 +8,6 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/env
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@ -98,15 +97,12 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
envelopeForSigning,
} as const;
})
.catch(async (e) => {
.catch((e) => {
const error = AppError.parseError(e);
if (error.code === AppErrorCode.UNAUTHORIZED) {
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
return {
isDocumentAccessValid: false,
...requiredAccessData,
} as const;
}
@ -226,20 +222,21 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
}
const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
const isEmailForced = derivedRecipientAccessAuth.includes(DocumentAccessAuth.ACCOUNT);
return (
<EnvelopeSigningProvider
envelopeData={data.envelopeForSigning}
email={''} // Doing this allows us to let users change the email if they want to.
email={isEmailForced ? user?.email || '' : ''} // Doing this allows us to let users change the email if they want to for non-auth templates.
fullName={user?.name}
signature={user?.signature}
>

View File

@ -30,4 +30,6 @@ server.use(
const handler = handle(build, server);
serve({ fetch: handler.fetch, port: 3000 });
const port = parseInt(process.env.PORT || '3000', 10);
serve({ fetch: handler.fetch, port });

View File

@ -1,10 +1,10 @@
import type { Context } from 'hono';
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { createOpenApiFetchHandler } from '@documenso/trpc/utils/openapi-fetch-handler';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
export const openApiTrpcServerHandler = async (c: Context) => {

View File

@ -21,7 +21,7 @@ export default defineConfig({
},
},
server: {
port: 3000,
port: parseInt(process.env.PORT || '3000', 10),
strictPort: true,
},
plugins: [

Binary file not shown.

BIN
assets/field-meta.pdf Normal file

Binary file not shown.

858
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,7 @@
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0",
"@prisma/client": "^6.8.2",
"@prisma/client": "^6.18.0",
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"eslint": "^8.40.0",
@ -54,11 +54,21 @@
"nodemailer": "^6.10.1",
"playwright": "1.52.0",
"prettier": "^3.3.3",
"prisma": "^6.8.2",
"prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1",
"turbo": "^1.9.3",
"@trpc/client": "11.7.0",
"@trpc/react-query": "11.7.0",
"@trpc/server": "11.7.0",
"superjson": "^2.2.5",
"trpc-to-openapi": "2.4.0",
"zod-openapi": "^4.2.4",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"zod-prisma-types": "3.3.5",
"vite": "^6.3.5"
},
"name": "@documenso/root",
@ -76,12 +86,12 @@
"mupdf": "^1.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "3.24.1"
"zod": "^3.25.76"
},
"overrides": {
"zod": "3.24.1"
"zod": "^3.25.76"
},
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}

View File

@ -17,14 +17,14 @@
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
"@ts-rest/serverless": "^3.30.5",
"@ts-rest/core": "^3.52.0",
"@ts-rest/open-api": "^3.52.0",
"@ts-rest/serverless": "^3.52.0",
"@types/swagger-ui-react": "^5.18.0",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
"superjson": "^2.2.5",
"swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76"
}
}

View File

@ -20,12 +20,12 @@ import {
getEnvelopeWhereInput,
} from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
import { updateDocumentFields } from '@documenso/lib/server-only/field/update-document-fields';
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient';
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
@ -1285,7 +1285,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const updatedRecipient = await updateDocumentRecipients({
const updatedRecipient = await updateEnvelopeRecipients({
userId: user.id,
teamId: team.id,
id: {
@ -1336,7 +1336,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
},
});
const deletedRecipient = await deleteDocumentRecipient({
const deletedRecipient = await deleteEnvelopeRecipient({
userId: user.id,
teamId: team.id,
recipientId: Number(recipientId),
@ -1634,10 +1634,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const { fields } = await updateDocumentFields({
const { fields } = await updateEnvelopeFields({
userId: user.id,
teamId: team.id,
documentId: legacyDocumentId,
id: {
type: 'documentId',
id: legacyDocumentId,
},
fields: [
{
id: Number(fieldId),

View File

@ -456,22 +456,10 @@ const ZCreateFieldSchema = z.object({
recipientId: z.number(),
type: z.nativeEnum(FieldType),
pageNumber: z.number(),
pageX: z
.number()
.min(0, 'Must be between 0-100 (percentage of page width)')
.max(100, 'Must be between 0-100 (percentage of page width)'),
pageY: z
.number()
.min(0, 'Must be between 0-100 (percentage of page height)')
.max(100, 'Must be between 0-100 (percentage of page height)'),
pageWidth: z
.number()
.min(0, 'Must be between 0-100 (percentage of page width)')
.max(100, 'Must be between 0-100 (percentage of page width)'),
pageHeight: z
.number()
.min(0, 'Must be between 0-100 (percentage of page height)')
.max(100, 'Must be between 0-100 (percentage of page height)'),
pageX: z.number(),
pageY: z.number(),
pageWidth: z.number(),
pageHeight: z.number(),
fieldMeta: ZFieldMetaSchema.openapi({}),
});

View File

@ -0,0 +1,498 @@
import { FieldType } from '@prisma/client';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
export type FieldTestData = TFieldAndMeta & {
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
customText: string;
signature?: string;
};
const columnWidth = 19.125;
const rowHeight = 6.7;
const alignmentGridStartX = 31;
const alignmentGridStartY = 19.02;
export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
/**
* Row 1 EMAIL
*/
{
type: FieldType.EMAIL,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'email',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'admin@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: {
textAlign: 'center',
type: 'email',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'admin@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'email',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'admin@documenso.com',
},
/**
* Row 2 NAME
*/
{
type: FieldType.NAME,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'name',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'John Doe',
},
{
type: FieldType.NAME,
fieldMeta: {
textAlign: 'center',
type: 'name',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'John Doe',
},
{
type: FieldType.NAME,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'name',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'John Doe',
},
/**
* Row 3 DATE
*/
{
type: FieldType.DATE,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'date',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.DATE,
fieldMeta: {
textAlign: 'center',
type: 'date',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.DATE,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'date',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
/**
* Row 4 TEXT
*/
{
type: FieldType.TEXT,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'text',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'center',
type: 'text',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'text',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
/**
* Row 5 NUMBER
*/
{
type: FieldType.NUMBER,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'number',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'center',
type: 'number',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'number',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
/**
* Row 6 Initials
*/
{
type: FieldType.INITIALS,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'initials',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'JD',
},
{
type: FieldType.INITIALS,
fieldMeta: {
textAlign: 'center',
type: 'initials',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'JD',
},
{
type: FieldType.INITIALS,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'initials',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'JD',
},
/**
* Row 7 Radio
*/
{
type: FieldType.RADIO,
fieldMeta: {
fontSize: 10,
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '0',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '2',
},
{
type: FieldType.RADIO,
fieldMeta: {
fontSize: 20,
direction: 'horizontal',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
},
/**
* Row 8 Checkbox
*/
{
type: FieldType.CHECKBOX,
fieldMeta: {
fontSize: 10,
direction: 'vertical',
type: 'checkbox',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: toCheckboxCustomText([0]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: toCheckboxCustomText([1]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
fontSize: 20,
direction: 'horizontal',
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
},
/**
* Row 8 Dropdown
*/
{
type: FieldType.DROPDOWN,
fieldMeta: {
fontSize: 10,
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
fontSize: 20,
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'Option 1',
},
/**
* Row 9 Signature
*/
{
type: FieldType.SIGNATURE,
fieldMeta: {
fontSize: 10,
type: 'signature',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
fontSize: 20,
type: 'signature',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
signature: 'My Signature',
},
] as const;
export const formatAlignmentTestFields = ALIGNMENT_TEST_FIELDS.map((field, index) => {
const row = Math.floor(index / 3);
const column = index % 3;
return {
...field,
positionX: alignmentGridStartX + column * columnWidth,
positionY: alignmentGridStartY + row * rowHeight,
};
});

View File

@ -0,0 +1,482 @@
import { FieldType } from '@prisma/client';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import {
CheckboxValidationRules,
numberFormatValues,
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import type { FieldTestData } from './field-alignment-pdf';
const columnWidth = 20.1;
const fullColumnWidth = 75.8;
const rowHeight = 9.8;
const rowPadding = 1.8;
const alignmentGridStartX = 11.85;
const alignmentGridStartY = 15.07;
const calculatePosition = (row: number, column: number, width: 'full' | 'column' = 'column') => {
return {
height: rowHeight,
width: width === 'full' ? fullColumnWidth : columnWidth,
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
};
};
export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
/**
* PAGE 2 Signature
*/
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(0, 0),
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(1, 0),
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(2, 0),
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(3, 0),
customText: '',
signature: 'My Signature',
},
/**
* PAGE 3 TEXT
*/
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
},
page: 3,
...calculatePosition(0, 0, 'full'),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
},
page: 3,
...calculatePosition(1, 0),
customText: '123456789123456789123456789123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
characterLimit: 5,
},
page: 3,
...calculatePosition(2, 0),
customText: '12345',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
placeholder: 'Demo Placeholder',
},
page: 3,
...calculatePosition(3, 0),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
label: 'Demo Label',
},
page: 3,
...calculatePosition(3, 1),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
text: 'Prefilled text',
},
page: 3,
...calculatePosition(3, 2),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
required: true,
},
page: 3,
...calculatePosition(4, 0),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
readOnly: true,
text: 'Readonly Value',
},
page: 3,
...calculatePosition(4, 1),
customText: 'Readonly Value',
},
/**
* PAGE 4 NUMBER
*/
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
},
page: 4,
...calculatePosition(0, 0, 'full'),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
},
page: 4,
...calculatePosition(1, 0),
customText: '123456789123456789123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
minValue: 0,
maxValue: 100,
},
page: 4,
...calculatePosition(2, 0),
customText: '50',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
numberFormat: numberFormatValues[0].value, // Todo: Envelopes - Check this.
value: '123,456,789.00',
},
page: 4,
...calculatePosition(2, 1),
customText: '123,456,789.00',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
placeholder: 'Demo Placeholder',
},
page: 4,
...calculatePosition(3, 0),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
label: 'Demo Label',
},
page: 4,
...calculatePosition(3, 1),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
value: '123',
},
page: 4,
...calculatePosition(3, 2),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
required: true,
},
page: 4,
...calculatePosition(4, 0),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
readOnly: true,
},
page: 4,
...calculatePosition(4, 1),
customText: '123456789',
},
/**
* PAGE 5 RADIO
*/
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'horizontal',
type: 'radio',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(0, 0, 'full'),
customText: '0',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(1, 0),
customText: '2',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(2, 0),
customText: '',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(2, 1),
customText: '',
},
/**
* PAGE 6 CHECKBOX
*/
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'horizontal',
type: 'checkbox',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 2, checked: false, value: 'Option 3' },
{ id: 2, checked: false, value: 'Option 4' },
],
},
page: 6,
...calculatePosition(0, 0, 'full'),
customText: toCheckboxCustomText([0]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 2, checked: true, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(1, 0),
customText: toCheckboxCustomText([1]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
required: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 6,
...calculatePosition(2, 0),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
readOnly: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 6,
...calculatePosition(2, 1),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
validationRule: CheckboxValidationRules.SELECT_AT_LEAST,
validationLength: 2,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(3, 0),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
validationRule: CheckboxValidationRules.SELECT_EXACTLY,
validationLength: 2,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(3, 1),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
validationRule: CheckboxValidationRules.SELECT_AT_MOST,
validationLength: 2,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(3, 2),
customText: '',
},
/**
* PAGE 7 DROPDOWN
*/
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
},
page: 7,
...calculatePosition(0, 0, 'full'),
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
defaultValue: 'Option 1',
},
page: 7,
...calculatePosition(1, 0),
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
required: true,
},
page: 7,
...calculatePosition(2, 0),
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
readOnly: true,
},
page: 7,
...calculatePosition(2, 1),
customText: 'Option 1',
},
] as const;
export const formatFieldMetaTestFields = FIELD_META_TEST_FIELDS.map((field, index) => {
return {
...field,
};
});

View File

@ -68,15 +68,29 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
// Test promoting a MEMBER to owner
const memberRow = page.getByRole('row', { name: memberUser.email });
// Find and click the "Promote to owner" button for the member
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton).toBeVisible();
await expect(promoteButton).not.toBeDisabled();
// Find and click the "Update role" button for the member
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await expect(updateRoleButton).toBeVisible();
await expect(updateRoleButton).not.toBeDisabled();
await promoteButton.click();
await updateRoleButton.click();
// Verify success toast appears
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload the page to see the changes
await page.reload();
@ -89,12 +103,18 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify that the promote button is now disabled for the new owner
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Verify that the Update role button exists for the new owner and shows Owner as current role
const newOwnerUpdateButton = newOwnerRow.getByRole('button', {
name: 'Update role',
});
await expect(newOwnerUpdateButton).toBeVisible();
// Test that we can't promote the current owner (button should be disabled)
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
// Verify clicking it shows the dialog with Owner already selected
await newOwnerUpdateButton.click();
await expect(page.getByRole('dialog')).toBeVisible();
// Close the dialog without making changes
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('[ADMIN]: promote manager to owner', async ({ page }) => {
@ -130,10 +150,26 @@ test('[ADMIN]: promote manager to owner', async ({ page }) => {
// Promote the manager to owner
const managerRow = page.getByRole('row', { name: managerUser.email });
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
const updateRoleButton = managerRow.getByRole('button', {
name: 'Update role',
});
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload and verify the change
await page.reload();
@ -173,14 +209,27 @@ test('[ADMIN]: promote admin member to owner', async ({ page }) => {
// Promote the admin member to owner
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
const updateRoleButton = adminMemberRow.getByRole('button', {
name: 'Update role',
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload and verify the change
await page.reload();
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
@ -249,11 +298,25 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Promote member to owner
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload page to see updated state
await page.reload();
@ -262,9 +325,11 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
memberRow = page.getByRole('row', { name: memberUser.email });
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
// Verify the promote button is now disabled for the new owner
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Verify the Update role button exists and shows Owner as current role
const newOwnerUpdateButton = memberRow.getByRole('button', {
name: 'Update role',
});
await expect(newOwnerUpdateButton).toBeVisible();
// Sign in as the newly promoted user to verify they have owner permissions
await apiSignin({
@ -336,28 +401,56 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
// First promotion: Member 1 becomes owner
let member1Row = page.getByRole('row', { name: member1User.email });
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await promoteButton1.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
let updateRoleButton1 = member1Row.getByRole('button', {
name: 'Update role',
});
await updateRoleButton1.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload();
// Verify Member 1 is now owner and button is disabled
// Verify Member 1 is now owner
member1Row = page.getByRole('row', { name: member1User.email });
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton1).toBeDisabled();
updateRoleButton1 = member1Row.getByRole('button', { name: 'Update role' });
await expect(updateRoleButton1).toBeVisible();
// Second promotion: Member 2 becomes the new owner
const member2Row = page.getByRole('row', { name: member2User.email });
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton2).not.toBeDisabled();
await promoteButton2.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
const updateRoleButton2 = member2Row.getByRole('button', {
name: 'Update role',
});
await expect(updateRoleButton2).toBeVisible();
await updateRoleButton2.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload();
@ -365,9 +458,11 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify Member 1's promote button is now enabled again
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(newPromoteButton1).not.toBeDisabled();
// Verify Member 1's Update role button is still visible
const newUpdateButton1 = member1Row.getByRole('button', {
name: 'Update role',
});
await expect(newUpdateButton1).toBeVisible();
});
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
@ -402,11 +497,25 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
});
const memberRow = page.getByRole('row', { name: memberUser.email });
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Test that the new owner can access organisation settings
await apiSignin({

View File

@ -0,0 +1,560 @@
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { pick } from 'remeda';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import {
DocumentDistributionMethod,
DocumentSigningOrder,
DocumentStatus,
DocumentVisibility,
EnvelopeType,
FieldType,
FolderType,
RecipientRole,
} from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TCreateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import { formatAlignmentTestFields } from '../../../constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('API V2 Envelopes', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test.describe('Envelope create endpoint', () => {
test('should fail on invalid form', async ({ request }) => {
const payload = {
type: 'Invalid Type',
title: 'Test Envelope',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('should create envelope with single file', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Test Envelope',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUnique({
where: {
id: response.id,
},
include: {
envelopeItems: true,
},
});
expect(envelope).toBeDefined();
expect(envelope?.title).toBe('Test Envelope');
expect(envelope?.type).toBe(EnvelopeType.TEMPLATE);
expect(envelope?.status).toBe(DocumentStatus.DRAFT);
expect(envelope?.envelopeItems.length).toBe(1);
expect(envelope?.envelopeItems[0].title).toBe('field-font-alignment.pdf');
expect(envelope?.envelopeItems[0].documentDataId).toBeDefined();
});
test('should create envelope with multiple file', async ({ request }) => {
const folder = await prisma.folder.create({
data: {
name: 'Test Folder',
teamId: teamA.id,
userId: userA.id,
type: FolderType.DOCUMENT,
},
});
const payload = {
title: 'Envelope Title',
type: EnvelopeType.DOCUMENT,
externalId: 'externalId',
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
formValues: {
hello: 'world',
},
folderId: folder.id,
recipients: [
{
email: userA.email,
name: 'Name',
role: RecipientRole.SIGNER,
accessAuth: ['TWO_FACTOR_AUTH'],
signingOrder: 1,
fields: [
{
type: FieldType.SIGNATURE,
identifier: 'field-font-alignment.pdf',
page: 1,
positionX: 0,
positionY: 0,
width: 0,
height: 0,
},
{
type: FieldType.SIGNATURE,
identifier: 0,
page: 1,
positionX: 0,
positionY: 0,
width: 0,
height: 0,
},
],
},
],
meta: {
subject: 'Subject',
message: 'Message',
timezone: 'Europe/Berlin',
dateFormat: 'dd.MM.yyyy',
distributionMethod: DocumentDistributionMethod.NONE,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
redirectUrl: 'https://documenso.com',
language: 'de',
typedSignatureEnabled: true,
uploadSignatureEnabled: false,
drawSignatureEnabled: false,
emailReplyTo: userA.email,
emailSettings: {
recipientSigningRequest: false,
recipientRemoved: false,
recipientSigned: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: true,
},
},
attachments: [
{
label: 'Test Attachment',
data: 'https://documenso.com',
type: 'link',
},
],
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'field-meta.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf')),
},
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
// Should error since folder is not owned by the user.
const invalidRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(invalidRes.ok()).toBeFalsy();
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: response.id,
},
include: {
documentMeta: true,
envelopeItems: true,
recipients: true,
fields: true,
envelopeAttachments: true,
},
});
console.log(userB.email);
expect(envelope.envelopeItems.length).toBe(2);
expect(envelope.envelopeItems[0].title).toBe('field-meta.pdf');
expect(envelope.envelopeItems[1].title).toBe('field-font-alignment.pdf');
expect(envelope.title).toBe(payload.title);
expect(envelope.type).toBe(payload.type);
expect(envelope.externalId).toBe(payload.externalId);
expect(envelope.visibility).toBe(payload.visibility);
expect(envelope.authOptions).toEqual({
globalAccessAuth: payload.globalAccessAuth,
globalActionAuth: [],
});
expect(envelope.formValues).toEqual(payload.formValues);
expect(envelope.folderId).toBe(payload.folderId);
expect(envelope.documentMeta.subject).toBe(payload.meta.subject);
expect(envelope.documentMeta.message).toBe(payload.meta.message);
expect(envelope.documentMeta.timezone).toBe(payload.meta.timezone);
expect(envelope.documentMeta.dateFormat).toBe(payload.meta.dateFormat);
expect(envelope.documentMeta.distributionMethod).toBe(payload.meta.distributionMethod);
expect(envelope.documentMeta.signingOrder).toBe(payload.meta.signingOrder);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(
payload.meta.allowDictateNextSigner,
);
expect(envelope.documentMeta.redirectUrl).toBe(payload.meta.redirectUrl);
expect(envelope.documentMeta.language).toBe(payload.meta.language);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(
payload.meta.uploadSignatureEnabled,
);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
expect([
{
label: envelope.envelopeAttachments[0].label,
data: envelope.envelopeAttachments[0].data,
type: envelope.envelopeAttachments[0].type,
},
]).toEqual(payload.attachments);
const field = envelope.fields[0];
const recipient = envelope.recipients[0];
expect({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.authOptions?.accessAuth,
}).toEqual(
pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']),
);
expect({
type: field.type,
page: field.page,
positionX: field.positionX.toNumber(),
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
}).toEqual(
pick(payload.recipients[0].fields[0], [
'type',
'page',
'positionX',
'positionY',
'width',
'height',
]),
);
// Expect string based ID to work.
expect(field.envelopeItemId).toBe(
envelope.envelopeItems.find((item) => item.title === 'field-font-alignment.pdf')?.id,
);
// Expect index based ID to work.
expect(envelope.fields[1].envelopeItemId).toBe(
envelope.envelopeItems.find((item) => item.title === 'field-meta.pdf')?.id,
);
});
});
/**
* Creates envelopes with the two field test PDFs.
*/
test('Envelope full test', async ({ request }) => {
// Step 1: Create initial envelope with Prisma (with first envelope item)
const alignmentPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
);
const fieldMetaPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
);
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Envelope Full Field Test',
} satisfies TCreateEnvelopePayload),
);
// Only add one file for now.
formData.append(
'files',
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
const response: TCreateEnvelopeResponse = await res.json();
const createdEnvelope: TGetEnvelopeResponse = await request
.get(`${baseUrl}/envelope/${response.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
})
.then(async (res) => await res.json());
// Might as well testing access control here as well.
const unauthRequest = await request.get(`${baseUrl}/envelope/${response.id}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(unauthRequest.ok()).toBeFalsy();
expect(unauthRequest.status()).toBe(404);
// Step 2: Create second envelope item via API
// Todo: Envelopes - Use API Route
const fieldMetaDocumentData = await prisma.documentData.create({
data: {
type: 'BYTES_64',
data: fieldMetaPdf.toString('base64'),
initialData: fieldMetaPdf.toString('base64'),
},
});
const createEnvelopeItemsRequest: TCreateEnvelopeItemsRequest = {
envelopeId: createdEnvelope.id,
data: [
{
title: 'Field Meta Test',
documentDataId: fieldMetaDocumentData.id,
},
],
};
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createEnvelopeItemsRequest,
});
expect(createItemsRes.ok()).toBeTruthy();
expect(createItemsRes.status()).toBe(200);
// Step 3: Update envelope via API
const updateEnvelopeRequest: TUpdateEnvelopeRequest = {
envelopeId: createdEnvelope.id,
data: {
title: 'Envelope Full Field Test',
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
};
const updateRes = await request.post(`${baseUrl}/envelope/update`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: updateEnvelopeRequest,
});
expect(updateRes.ok()).toBeTruthy();
expect(updateRes.status()).toBe(200);
// Step 4: Create recipient via API
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createdEnvelope.id,
data: [
{
email: userA.email,
name: userA.name || '',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
expect(createRecipientsRes.status()).toBe(200);
// Step 5: Get envelope to retrieve recipients and envelope items
const getRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(getRes.ok()).toBeTruthy();
expect(getRes.status()).toBe(200);
const envelopeResponse = (await getRes.json()) as TGetEnvelopeResponse;
const recipientId = envelopeResponse.recipients[0].id;
const alignmentItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 1,
);
const fieldMetaItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 2,
);
expect(recipientId).toBeDefined();
expect(alignmentItem).toBeDefined();
expect(fieldMetaItem).toBeDefined();
if (!alignmentItem || !fieldMetaItem) {
throw new Error('Envelope items not found');
}
// Step 6: Create fields for first PDF (alignment fields)
const alignmentFieldsRequest = {
envelopeId: createdEnvelope.id,
data: formatAlignmentTestFields.map((field) => ({
recipientId,
envelopeItemId: alignmentItem.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
})),
};
const createAlignmentFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: alignmentFieldsRequest,
});
expect(createAlignmentFieldsRes.ok()).toBeTruthy();
expect(createAlignmentFieldsRes.status()).toBe(200);
// Step 7: Create fields for second PDF (field-meta fields)
const fieldMetaFieldsRequest = {
envelopeId: createdEnvelope.id,
data: FIELD_META_TEST_FIELDS.map((field) => ({
recipientId,
envelopeItemId: fieldMetaItem.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
})),
};
const createFieldMetaFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: fieldMetaFieldsRequest,
});
expect(createFieldMetaFieldsRes.ok()).toBeTruthy();
expect(createFieldMetaFieldsRes.status()).toBe(200);
// Step 8: Verify final envelope structure
const finalGetRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(finalGetRes.ok()).toBeTruthy();
const finalEnvelope = (await finalGetRes.json()) as TGetEnvelopeResponse;
// Verify structure
expect(finalEnvelope.envelopeItems.length).toBe(2);
expect(finalEnvelope.recipients.length).toBe(1);
expect(finalEnvelope.fields.length).toBe(
formatAlignmentTestFields.length + FIELD_META_TEST_FIELDS.length,
);
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
});
});

View File

@ -0,0 +1,282 @@
// sort-imports-ignore
// ---- PATCH pdfjs-dist's canvas require BEFORE importing it ----
import Module from 'module';
import { Canvas, Image } from 'skia-canvas';
// Intercept require('canvas') and return skia-canvas equivalents
const originalRequire = Module.prototype.require;
Module.prototype.require = function (path: string) {
if (path === 'canvas') {
return {
createCanvas: (width: number, height: number) => new Canvas(width, height),
Image, // needed by pdfjs-dist
};
}
// eslint-disable-next-line prefer-rest-params, @typescript-eslint/consistent-type-assertions
return originalRequire.apply(this, arguments as unknown as [string]);
};
import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
test('field placement visual regression', async ({ page }, testInfo) => {
const { user, team } = await seedUser();
const envelope = await seedAlignmentTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
status: DocumentStatus.PENDING,
});
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
await apiSignin({
page,
email: user.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await expect(async () => {
const { status } = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass({
timeout: 10000,
});
const completedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
include: {
documentData: true,
},
},
},
});
const storedImages = fs.readdirSync(path.join(__dirname, '../../visual-regression'));
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const pdfData = await getFile(item.documentData);
const loadedImages = storedImages
.filter((image) => image.includes(item.title))
.map((image) => fs.readFileSync(path.join(__dirname, '../../visual-regression', image)));
await compareSignedPdfWithImages({
id: item.title.replaceAll(' ', '-').toLowerCase(),
pdfData,
images: loadedImages,
testInfo,
});
}),
);
});
/**
* Used to download the envelope images when updating the visual regression test.
*
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('download envelope images', async ({ page }) => {
const { user, team } = await seedUser();
const envelope = await seedAlignmentTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
status: DocumentStatus.PENDING,
});
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
await apiSignin({
page,
email: user.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await expect(async () => {
const { status } = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass({
timeout: 10000,
});
const completedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
include: {
documentData: true,
},
},
},
});
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const pdfData = await getFile(item.documentData);
const pdfImages = await renderPdfToImage(pdfData);
for (const [index, { image }] of pdfImages.entries()) {
fs.writeFileSync(
path.join(__dirname, '../../visual-regression', `${item.title}-${index}.png`),
new Uint8Array(image),
);
}
}),
);
});
async function renderPdfToImage(pdfBytes: Uint8Array) {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
// Increase for higher resolution
const scale = 4;
return await Promise.all(
Array.from({ length: pdf.numPages }, async (_, index) => {
const page = await pdf.getPage(index + 1);
const viewport = page.getViewport({ scale });
const virtualCanvas = new Canvas(viewport.width, viewport.height);
const context = virtualCanvas.getContext('2d');
context.imageSmoothingEnabled = false;
// @ts-expect-error skia-canvas context satisfies runtime requirements for pdfjs
await page.render({ canvasContext: context, viewport }).promise;
return {
image: await virtualCanvas.toBuffer('png'),
// Rounded down because the certificate page somehow gives dimensions with decimals
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
};
}),
);
}
type CompareSignedPdfWithImagesOptions = {
id: string;
pdfData: Uint8Array;
images: Buffer[];
testInfo: TestInfo;
};
const compareSignedPdfWithImages = async ({
id,
pdfData,
images,
testInfo,
}: CompareSignedPdfWithImagesOptions) => {
const renderedImages = await renderPdfToImage(pdfData);
const blankCertificateFile = fs.readFileSync(
path.join(__dirname, '../../visual-regression/blank-certificate.png'),
);
const blankCertificateImage = PNG.sync.read(blankCertificateFile).data;
for (const [index, { image, width, height }] of renderedImages.entries()) {
const isCertificate = index === renderedImages.length - 1;
const diff = new PNG({ width, height });
const storedImage = PNG.sync.read(images[index]).data;
const newImage = PNG.sync.read(image).data;
const oldImage = isCertificate ? blankCertificateImage : storedImage;
const comparison = pixelMatch(
new Uint8Array(oldImage),
new Uint8Array(newImage),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
diff.data as unknown as Uint8Array,
width,
height,
{
threshold: 0.25,
// includeAA: true, // This allows stricter testing.
},
);
console.log(`${id}-${index}: ${comparison}`);
const diffFilePath = path.join(testInfo.outputPath(), `${id}-${index}-diff.png`);
const oldFilePath = path.join(testInfo.outputPath(), `${id}-${index}-old.png`);
const newFilePath = path.join(testInfo.outputPath(), `${id}-${index}-new.png`);
fs.writeFileSync(diffFilePath, new Uint8Array(PNG.sync.write(diff)));
fs.writeFileSync(oldFilePath, new Uint8Array(images[index]));
fs.writeFileSync(newFilePath, new Uint8Array(image));
if (isCertificate) {
// Expect the certificate to NOT be blank. Since the storedImage is blank.
expect.soft(comparison).toBeGreaterThan(20000);
} else {
expect.soft(comparison).toEqual(0);
}
}
};

View File

@ -1,9 +1,12 @@
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
@ -121,7 +124,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
await expect(page.getByText('404 not found')).toBeVisible();
});
test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => {
test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
@ -153,6 +156,53 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) =>
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
});
test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
title: 'Personal direct template link',
userId: user.id,
teamId: team.id,
internalVersion: 2,
createTemplateOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
});
const directTemplatePath = formatDirectTemplatePath(
directTemplateWithAuth.directLink?.token || '',
);
await page.goto(directTemplatePath);
await expect(page.getByText('Authentication required')).toBeVisible();
await apiSignin({
page,
email: user.email,
});
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
});
test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => {
@ -175,6 +225,9 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
@ -183,3 +236,173 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
// Add a longer waiting period to ensure document status is updated
await page.waitForTimeout(3000);
});
test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});
test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
internalVersion: 2,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByRole('button', { name: 'Complete' }).click();
const currentName = 'John Doe';
const currentEmail = seedTestEmail();
await page.getByPlaceholder('Enter Your Name').fill(currentName);
await page.getByPlaceholder('Enter Your Email').fill(currentEmail);
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});

View File

@ -15,7 +15,10 @@
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@playwright/test": "1.52.0",
"@types/node": "^20"
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0"
},
"dependencies": {
"start-server-and-test": "^2.0.12"

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -20,6 +20,6 @@
"luxon": "^3.5.0",
"nanoid": "^5.1.5",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76"
}
}

View File

@ -19,6 +19,6 @@
"micro": "^10.0.1",
"react": "^18",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76"
}
}

View File

@ -50,6 +50,7 @@ type UseEditorFieldsResponse = {
// Field operations
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
setFieldId: (formId: string, id: number) => void;
removeFieldsByFormId: (formIds: string[]) => void;
updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void;
duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
@ -160,6 +161,17 @@ export const useEditorFields = ({
[localFields, remove, triggerFieldsUpdate],
);
const setFieldId = (formId: string, id: number) => {
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, {
...localFields[index],
id,
});
}
};
const updateFieldByFormId = useCallback(
(formId: string, updates: Partial<TLocalField>) => {
const index = localFields.findIndex((field) => field.formId === formId);
@ -269,6 +281,7 @@ export const useEditorFields = ({
// Field operations
addField,
setFieldId,
removeFieldsByFormId,
updateFieldByFormId,
duplicateField,

View File

@ -97,6 +97,11 @@ export const EnvelopeEditorProvider = ({
const [envelope, setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
setEnvelope({
@ -184,13 +189,24 @@ export const EnvelopeEditorProvider = ({
triggerSave: setFieldsDebounced,
flush: setFieldsAsync,
isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (fields: TLocalField[]) => {
await envelopeFieldSetMutationQuery.mutateAsync({
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields,
fields: localFields,
});
}, 1000);
// Insert the IDs into the local fields.
envelopeFields.fields.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
}, 2000);
const {
triggerSave: setEnvelopeDebounced,
@ -199,7 +215,6 @@ export const EnvelopeEditorProvider = ({
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: envelopeUpdates.data,
meta: envelopeUpdates.meta,
});
@ -221,11 +236,6 @@ export const EnvelopeEditorProvider = ({
setEnvelopeDebounced(envelopeUpdates);
};
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = envelope.recipients.findIndex(

View File

@ -3,6 +3,9 @@ import React from 'react';
import type { DocumentData } from '@prisma/client';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope';
import { getFile } from '../../universal/upload/get-file';
@ -23,6 +26,7 @@ type EnvelopeRenderProviderValue = {
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields'];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
};
interface EnvelopeRenderProviderProps {
@ -35,6 +39,13 @@ interface EnvelopeRenderProviderProps {
* Only pass if the CustomRenderer you are passing in wants fields.
*/
fields?: TEnvelope['fields'];
/**
* Optional recipient IDs used to determine the color of the fields.
*
* Only required for generic page renderers.
*/
recipientIds?: number[];
}
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
@ -56,6 +67,7 @@ export const EnvelopeRenderProvider = ({
children,
envelope,
fields,
recipientIds = [],
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
@ -132,6 +144,17 @@ export const EnvelopeRenderProvider = ({
}
}, [envelope.envelopeItems]);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
[recipientIds],
);
return (
<EnvelopeRenderContext.Provider
value={{
@ -140,6 +163,7 @@ export const EnvelopeRenderProvider = ({
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
getRecipientColorKey,
}}
>
{children}

View File

@ -14,3 +14,5 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
export const API_V2_BETA_URL = '/api/v2-beta';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
export const IS_ENVELOPES_ENABLED = env('NEXT_PUBLIC_FEATURE_ENVELOPES_ENABLED') === 'true';

View File

@ -189,7 +189,6 @@ export const run = async ({
settings,
});
// Todo: Envelopes - Is it okay to have dynamic IDs?
const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) =>
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {

View File

@ -55,11 +55,11 @@
"skia-canvas": "^3.0.8",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76"
},
"devDependencies": {
"@playwright/browser-chromium": "1.52.0",
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
}
}

View File

@ -1,7 +1,5 @@
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -215,13 +213,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
teamId,
deletedAt: null,
folderId,
};
let notSignedCountsGroupByArgs = null;
@ -265,8 +264,16 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
ownerCountsWhereInput = {
...ownerCountsWhereInput,
...visibilityFiltersWhereInput,
...searchFilter,
AND: [
...(Array.isArray(visibilityFiltersWhereInput.AND)
? visibilityFiltersWhereInput.AND
: visibilityFiltersWhereInput.AND
? [visibilityFiltersWhereInput.AND]
: []),
searchFilter,
rootPageFilter,
folderId ? { folderId } : {},
],
};
if (teamEmail) {
@ -285,6 +292,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
],
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
};
notSignedCountsGroupByArgs = {
@ -296,7 +304,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
@ -306,6 +313,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
@ -318,7 +326,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
@ -342,6 +349,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
}

View File

@ -20,7 +20,12 @@ import { validateCheckboxLength } from '../../advanced-fields-validation/validat
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldAndMetaSchema,
ZRadioFieldMeta,
} from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -174,9 +179,20 @@ export const sendDocument = async ({
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
// Auto insert radio and checkboxes that have default values.
// Validate and autoinsert fields for V2 envelopes.
if (envelope.internalVersion === 2) {
for (const field of envelope.fields) {
for (const unknownField of envelope.fields) {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
if (parsedField.error) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
});
}
const field = parsedField.data;
const fieldId = unknownField.id;
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
@ -184,7 +200,7 @@ export const sendDocument = async ({
if (checkedItemIndex !== -1) {
fieldsToAutoInsert.push({
fieldId: field.id,
fieldId,
customText: toRadioCustomText(checkedItemIndex),
});
}
@ -195,7 +211,7 @@ export const sendDocument = async ({
if (defaultValue && values.some((value) => value.value === defaultValue)) {
fieldsToAutoInsert.push({
fieldId: field.id,
fieldId,
customText: defaultValue,
});
}
@ -236,7 +252,7 @@ export const sendDocument = async ({
if (isValid) {
fieldsToAutoInsert.push({
fieldId: field.id,
fieldId,
customText: toCheckboxCustomText(checkedIndices),
});
}
@ -256,11 +272,10 @@ export const sendDocument = async ({
});
}
// Todo: Envelopes - [AUDIT_LOGS]
if (envelope.internalVersion === 2) {
await Promise.all(
const autoInsertedFields = await Promise.all(
fieldsToAutoInsert.map(async (field) => {
await tx.field.update({
return await tx.field.update({
where: {
id: field.fieldId,
},
@ -271,6 +286,21 @@ export const sendDocument = async ({
});
}),
);
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED,
envelopeId: envelope.id,
data: {
fields: autoInsertedFields.map((field) => ({
fieldId: field.id,
fieldType: field.type,
recipientId: field.recipientId,
})),
},
// Don't put metadata or user here since it's a system event.
}),
});
}
return await tx.envelope.update({

View File

@ -16,11 +16,16 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type {
TDocumentAccessAuthTypes,
TDocumentActionAuthTypes,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
} from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -34,6 +39,25 @@ import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
documentDataId: string;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
type CreateEnvelopeRecipientOptions = {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
fields?: CreateEnvelopeRecipientFieldOptions[];
};
export type CreateEnvelopeOptions = {
userId: number;
teamId: number;
@ -46,7 +70,6 @@ export type CreateEnvelopeOptions = {
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
formValues?: TDocumentFormValues;
timezone?: string;
userTimezone?: string;
templateType?: TemplateType;
@ -56,7 +79,7 @@ export type CreateEnvelopeOptions = {
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: TCreateEnvelopeRequest['recipients'];
recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string;
};
attachments?: Array<{
@ -83,7 +106,6 @@ export const createEnvelope = async ({
title,
externalId,
formValues,
timezone,
userTimezone,
folderId,
templateType,
@ -142,6 +164,7 @@ export const createEnvelope = async ({
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
data.envelopeItems;
// Todo: Envelopes - Remove
if (normalizePdf) {
envelopeItems = await Promise.all(
data.envelopeItems.map(async (item) => {
@ -219,7 +242,7 @@ export const createEnvelope = async ({
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {

View File

@ -1,10 +1,11 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@ -98,14 +99,28 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: envelope.authOptions,
recipient,
userId,
authOptions: accessAuth,
// Currently not using this since for direct templates "User" access means they just need to be
// logged in.
// const documentAccessValid = await isRecipientAuthorized({
// type: 'ACCESS',
// documentAuthOptions: envelope.authOptions,
// recipient,
// userId,
// authOptions: accessAuth,
// });
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
// Ensure typesafety when we add more options.
const documentAccessValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(userId))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',

View File

@ -54,54 +54,3 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};

View File

@ -26,9 +26,9 @@ export interface CreateEnvelopeFieldsOptions {
envelopeItemId?: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
})[];
@ -122,9 +122,9 @@ export const createEnvelopeFields = async ({
const newlyCreatedFields = await tx.field.createManyAndReturn({
data: validatedFields.map((field) => ({
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',

View File

@ -11,7 +11,7 @@ export type GetFieldByIdOptions = {
userId: number;
teamId: number;
fieldId: number;
envelopeType: EnvelopeType;
envelopeType?: EnvelopeType;
};
export const getFieldById = async ({
@ -41,7 +41,7 @@ export const getFieldById = async ({
type: 'envelopeId',
id: field.envelopeId,
},
type: envelopeType,
type: envelopeType ?? null,
userId,
teamId,
});

View File

@ -158,7 +158,7 @@ export const setFieldsForDocument = async ({
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
String(numberFieldParsedMeta.value || ''),
numberFieldParsedMeta,
false,
);
@ -306,7 +306,10 @@ export const setFieldsForDocument = async ({
});
}
return upsertedField;
return {
...upsertedField,
formId: field.formId,
};
}),
);
});
@ -340,17 +343,25 @@ export const setFieldsForDocument = async ({
}
// Filter out fields that have been removed or have been updated.
const filteredFields = existingFields.filter((field) => {
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
const mappedFilteredFields = existingFields
.filter((field) => {
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
return !isRemoved && !isUpdated;
});
return !isRemoved && !isUpdated;
})
.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: undefined,
}));
const mappedPersistentFields = persistedFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: field?.formId,
}));
return {
fields: [...filteredFields, ...persistedFields].map((field) =>
mapFieldToLegacyField(field, envelope),
),
fields: [...mappedFilteredFields, ...mappedPersistentFields],
};
};
@ -359,6 +370,7 @@ export const setFieldsForDocument = async ({
*/
type FieldData = {
id?: number | null;
formId?: string;
envelopeItemId: string;
type: FieldType;
recipientId: number;

View File

@ -27,6 +27,7 @@ export type SetFieldsForTemplateOptions = {
id: EnvelopeIdOptions;
fields: {
id?: number | null;
formId?: string;
envelopeItemId: string;
type: FieldType;
recipientId: number;
@ -111,10 +112,10 @@ export const setFieldsForTemplate = async ({
};
});
const persistedFields = await prisma.$transaction(
const persistedFields = await Promise.all(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map((field) => {
linkedFields.map(async (field) => {
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
if (field.type === FieldType.TEXT && field.fieldMeta) {
@ -176,7 +177,7 @@ export const setFieldsForTemplate = async ({
}
// Proceed with upsert operation
return prisma.field.upsert({
const upsertedField = await prisma.field.upsert({
where: {
id: field._persisted?.id ?? -1,
envelopeId: envelope.id,
@ -219,6 +220,11 @@ export const setFieldsForTemplate = async ({
},
},
});
return {
...upsertedField,
formId: field.formId,
};
}),
);
@ -240,9 +246,17 @@ export const setFieldsForTemplate = async ({
return !isRemoved && !isUpdated;
});
const mappedFilteredFields = filteredFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: undefined,
}));
const mappedPersistentFields = persistedFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: field?.formId,
}));
return {
fields: [...filteredFields, ...persistedFields].map((field) =>
mapFieldToLegacyField(field, envelope),
),
fields: [...mappedFilteredFields, ...mappedPersistentFields],
};
};

View File

@ -10,18 +10,21 @@ import {
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentFieldsOptions {
export interface UpdateEnvelopeFieldsOptions {
userId: number;
teamId: number;
documentId: number;
id: EnvelopeIdOptions;
type?: EnvelopeType | null; // Only used to enforce the type.
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
envelopeItemId?: string;
pageX?: number;
pageY?: number;
width?: number;
@ -31,19 +34,17 @@ export interface UpdateDocumentFieldsOptions {
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentFields = async ({
export const updateEnvelopeFields = async ({
userId,
teamId,
documentId,
id,
type = null,
fields,
requestMetadata,
}: UpdateDocumentFieldsOptions) => {
}: UpdateEnvelopeFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
id,
type,
userId,
teamId,
});
@ -53,18 +54,19 @@ export const updateDocumentFields = async ({
include: {
recipients: true,
fields: true,
envelopeItems: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -96,6 +98,29 @@ export const updateDocumentFields = async ({
});
}
const fieldType = field.type || originalField.type;
const fieldMetaType = field.fieldMeta?.type || originalField.fieldMeta?.type;
// Not going to mess with V1 envelopes.
if (
envelope.internalVersion === 2 &&
fieldMetaType &&
fieldMetaType.toLowerCase() !== fieldType.toLowerCase()
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Field meta type does not match the field type',
});
}
if (
field.envelopeItemId &&
!envelope.envelopeItems.some((item) => item.id === field.envelopeItemId)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item not found',
});
}
return {
originalField,
updateData: field,
@ -118,27 +143,30 @@ export const updateDocumentFields = async ({
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
envelopeItemId: updateData.envelopeItemId,
},
});
const changes = diffFieldChanges(originalField, updatedField);
// Handle field updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffFieldChanges(originalField, updatedField);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
}
}
return updatedField;

View File

@ -1,116 +0,0 @@
import { EnvelopeType, type FieldType } from '@prisma/client';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateFieldsOptions {
userId: number;
teamId: number;
templateId: number;
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
pageX?: number;
pageY?: number;
width?: number;
height?: number;
fieldMeta?: TFieldMetaSchema;
}[];
}
export const updateTemplateFields = async ({
userId,
teamId,
templateId,
fields,
}: UpdateTemplateFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const fieldsToUpdate = fields.map((field) => {
const originalField = envelope.fields.find((existingField) => existingField.id === field.id);
if (!originalField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Field with id ${field.id} not found`,
});
}
const recipient = envelope.recipients.find(
(recipient) => recipient.id === originalField.recipientId,
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient attached to field ${field.id} not found`,
});
}
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
});
}
return {
updateData: field,
};
});
const updatedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
fieldsToUpdate.map(async ({ updateData }) => {
const updatedField = await tx.field.update({
where: {
id: updateData.id,
},
data: {
type: updateData.type,
page: updateData.pageNumber,
positionX: updateData.pageX,
positionY: updateData.pageY,
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
},
});
return updatedField;
}),
);
});
return {
fields: updatedFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
};

View File

@ -1,13 +1,22 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer) => {
const pdfDoc = await PDFDocument.load(pdf).catch(() => null);
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
if (!pdfDoc) {
return pdf;
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is not a valid PDF',
});
});
if (pdfDoc.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
}
removeOptionalContentGroups(pdfDoc);

View File

@ -15,7 +15,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateDocumentRecipientsOptions {
export interface CreateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
@ -30,16 +30,16 @@ export interface CreateDocumentRecipientsOptions {
requestMetadata: ApiRequestMetadata;
}
export const createDocumentRecipients = async ({
export const createEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients: recipientsToCreate,
requestMetadata,
}: CreateDocumentRecipientsOptions) => {
}: CreateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +62,13 @@ export const createDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -112,21 +112,23 @@ export const createDocumentRecipients = async ({
});
// Handle recipient created audit log.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
}
return createdRecipient;
}),

View File

@ -1,115 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const createTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients: recipientsToCreate,
}: CreateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({
data: {
envelopeId: template.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
return createdRecipient;
}),
);
});
return {
recipients: createdRecipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, template),
),
};
};

View File

@ -14,26 +14,27 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteDocumentRecipientOptions {
export interface DeleteEnvelopeRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
requestMetadata: ApiRequestMetadata;
}
export const deleteDocumentRecipient = async ({
export const deleteEnvelopeRecipient = async ({
userId,
teamId,
recipientId,
requestMetadata,
}: DeleteDocumentRecipientOptions) => {
}: DeleteEnvelopeRecipientOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
id: recipientId,
@ -48,6 +49,9 @@ export const deleteDocumentRecipient = async ({
where: {
id: recipientId,
},
include: {
fields: true,
},
},
},
});
@ -89,24 +93,43 @@ export const deleteDocumentRecipient = async ({
});
}
const deletedRecipient = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
if (!canRecipientBeModified(recipientToDelete, recipientToDelete.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient has already interacted with the document.',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelope.id,
},
type: null,
userId,
teamId,
});
const deletedRecipient = await prisma.$transaction(async (tx) => {
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
});
}
return await tx.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
});
@ -116,7 +139,11 @@ export const deleteDocumentRecipient = async ({
).recipientRemoved;
// Send email to deleted recipient.
if (recipientToDelete.sendStatus === SendStatus.SENT && isRecipientRemovedEmailEnabled) {
if (
recipientToDelete.sendStatus === SendStatus.SENT &&
isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {

View File

@ -1,58 +0,0 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteTemplateRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
}
export const deleteTemplateRecipient = async ({
userId,
teamId,
recipientId,
}: DeleteTemplateRecipientOptions): Promise<void> => {
const recipientToDelete = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: {
type: EnvelopeType.TEMPLATE,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
if (!recipientToDelete) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipientToDelete.envelopeId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
if (!recipientToDelete || recipientToDelete.id !== recipientId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
await prisma.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
};

View File

@ -1,5 +1,4 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@ -16,29 +15,38 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { extractLegacyIds } from '../../universal/id';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentRecipientsOptions {
export interface UpdateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
recipients: RecipientData[];
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentRecipients = async ({
export const updateEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients,
requestMetadata,
}: UpdateDocumentRecipientsOptions) => {
}: UpdateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +70,13 @@ export const updateDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -160,24 +168,26 @@ export const updateDocumentRecipients = async ({
});
}
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
// Handle recipient updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
}
}
return updatedRecipient;
@ -188,19 +198,8 @@ export const updateDocumentRecipients = async ({
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
templateId: null,
...extractLegacyIds(envelope),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};
type RecipientData = {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};

View File

@ -1,168 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const updateTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients,
}: UpdateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !envelope.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
if (!originalRecipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with id ${recipient.id} not found`,
});
}
return {
originalRecipient,
recipientUpdateData: recipient,
};
});
const updatedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, recipientUpdateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
if (
recipientUpdateData.actionAuth !== undefined ||
recipientUpdateData.accessAuth !== undefined
) {
authOptions = createRecipientAuthOptions({
accessAuth: recipientUpdateData.accessAuth || authOptions.accessAuth,
actionAuth: recipientUpdateData.actionAuth || authOptions.actionAuth,
});
}
const mergedRecipient = {
...originalRecipient,
...recipientUpdateData,
};
const updatedRecipient = await tx.recipient.update({
where: {
id: originalRecipient.id,
envelopeId: envelope.id,
},
data: {
name: mergedRecipient.name,
email: mergedRecipient.email,
role: mergedRecipient.role,
signingOrder: mergedRecipient.signingOrder,
envelopeId: envelope.id,
sendStatus:
mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
mergedRecipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
authOptions,
},
include: {
fields: true,
},
});
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
originalRecipient.role !== updatedRecipient.role &&
(updatedRecipient.role === RecipientRole.CC ||
updatedRecipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId: updatedRecipient.id,
},
});
}
return updatedRecipient;
}),
);
});
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: null,
templateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};

View File

@ -3,6 +3,7 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
EnvelopeType,
@ -26,7 +27,7 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
@ -68,6 +69,10 @@ export type CreateDocumentFromDirectTemplateOptions = {
name?: string;
email: string;
};
nextSigner?: {
email: string;
name: string;
};
};
type CreatedDirectRecipientField = {
@ -92,6 +97,7 @@ export const createDocumentFromDirectTemplate = async ({
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
nextSigner,
requestMetadata,
user,
}: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => {
@ -128,6 +134,17 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
}
if (
nextSigner &&
(!directTemplateEnvelope.documentMeta?.allowDictateNextSigner ||
directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
});
}
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
directTemplateEnvelope.secondaryId,
);
@ -630,6 +647,77 @@ export const createDocumentFromDirectTemplate = async ({
}),
];
if (nextSigner) {
const pendingRecipients = await tx.recipient.findMany({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
envelopeId: createdEnvelope.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const nextRecipient = pendingRecipients[0];
if (nextRecipient) {
auditLogsToCreate.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: createdEnvelope.id,
user: {
name: user?.name || directRecipientName || '',
email: user?.email || directRecipientEmail,
},
metadata: requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
);
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
}
}
await tx.documentAuditLog.createMany({
data: auditLogsToCreate,
});

View File

@ -262,10 +262,20 @@ msgstr "{prefix} hat ein Feld hinzugefügt"
msgid "{prefix} added a recipient"
msgstr "{prefix} hat einen Empfänger hinzugefügt"
#. placeholder {0}: data.envelopeItemTitle
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} created an envelope item with title {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} created the document"
msgstr "{prefix} hat das Dokument erstellt"
#. placeholder {0}: data.envelopeItemTitle
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} deleted an envelope item with title {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} deleted the document"
msgstr "{prefix} hat das Dokument gelöscht"
@ -356,6 +366,7 @@ msgstr "{recipientActionVerb} Dokument"
msgid "{recipientActionVerb} the document to complete the process."
msgstr "{recipientActionVerb} das Dokument, um den Prozess abzuschließen."
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
msgid "{recipientCount} recipients"
msgstr "{recipientCount} Empfänger"
@ -1742,8 +1753,9 @@ msgstr ""
#: apps/remix/app/components/general/document/document-attachments-popover.tsx
msgid "Attachment removed successfully."
msgstr ""
msgstr "<<<<<<< Updated upstream======="
#: apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx
#: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx
#: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx
#: apps/remix/app/components/general/document/document-attachments-popover.tsx
@ -2158,6 +2170,10 @@ msgstr "Filter löschen"
msgid "Clear Signature"
msgstr "Unterschrift löschen"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Click here to add a recipient"
msgstr ""
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
msgid "Click here to get started"
msgstr "Klicken Sie hier, um zu beginnen"
@ -2280,6 +2296,7 @@ msgstr "Abgeschlossene Dokumente"
msgid "Completed Documents"
msgstr "Abgeschlossene Dokumente"
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
msgid "Completed on {formattedDate}"
msgstr "Abgeschlossen am {formattedDate}"
@ -2479,7 +2496,6 @@ msgid "Controls which signatures are allowed to be used when signing a document.
msgstr "Bestimmt, welche Signaturen beim Unterschreiben eines Dokuments verwendet werden dürfen."
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "Copied"
msgstr "Kopiert"
@ -2497,14 +2513,12 @@ msgstr "Kopiert"
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
#: packages/ui/components/document/document-share-button.tsx
msgid "Copied to clipboard"
msgstr "In die Zwischenablage kopiert"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "Copy"
msgstr "Kopieren"
@ -2522,6 +2536,7 @@ msgid "Copy Shareable Link"
msgstr "Kopiere den teilbaren Link"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Copy Signing Links"
msgstr "Signierlinks kopieren"
@ -3646,7 +3661,6 @@ msgstr "Legen Sie Ihr Dokument hier ab"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/lib/utils/fields.ts
msgid "Dropdown"
msgstr "Dropdown"
@ -4035,6 +4049,14 @@ msgstr ""
msgid "Envelope Item Count"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "Envelope item created"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "Envelope item deleted"
msgstr ""
#: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx
msgid "Envelope resent"
msgstr ""
@ -5544,7 +5566,6 @@ msgstr "Kein passender Empfänger mit dieser Beschreibung gefunden."
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "No recipients"
msgstr "Keine Empfänger"
@ -7066,6 +7087,10 @@ msgstr "Wählen Sie Mitglieder oder Gruppen von Mitgliedern, die dem Team hinzug
msgid "Select members to add to this team"
msgstr "Wählen Sie Mitglieder aus, die diesem Team hinzugefügt werden sollen"
#: packages/lib/utils/fields.ts
msgid "Select Option"
msgstr ""
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
msgid "Select passkey"
msgstr "Passkey auswählen"
@ -7870,6 +7895,15 @@ msgstr "E-Mail-Domains synchronisieren"
msgid "Sync failed, changes not saved"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgctxt "Audit log format"
msgid "System auto inserted fields"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "System auto inserted fields"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "System Requirements"
msgstr "Systemanforderungen"
@ -8412,7 +8446,6 @@ msgstr "Der Name des Unterzeichners"
#: apps/remix/app/components/general/avatar-with-recipient.tsx
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "The signing link has been copied to your clipboard."
msgstr "Der Signierlink wurde in die Zwischenablage kopiert."

View File

@ -257,10 +257,20 @@ msgstr "{prefix} added a field"
msgid "{prefix} added a recipient"
msgstr "{prefix} added a recipient"
#. placeholder {0}: data.envelopeItemTitle
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} created an envelope item with title {0}"
msgstr "{prefix} created an envelope item with title {0}"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} created the document"
msgstr "{prefix} created the document"
#. placeholder {0}: data.envelopeItemTitle
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} deleted an envelope item with title {0}"
msgstr "{prefix} deleted an envelope item with title {0}"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} deleted the document"
msgstr "{prefix} deleted the document"
@ -351,6 +361,7 @@ msgstr "{recipientActionVerb} document"
msgid "{recipientActionVerb} the document to complete the process."
msgstr "{recipientActionVerb} the document to complete the process."
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
msgid "{recipientCount} recipients"
msgstr "{recipientCount} recipients"
@ -1737,8 +1748,9 @@ msgstr "Attachment added successfully."
#: apps/remix/app/components/general/document/document-attachments-popover.tsx
msgid "Attachment removed successfully."
msgstr "Attachment removed successfully."
msgstr "Attachment removed successfully.<<<<<<< Updated upstream======="
#: apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx
#: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx
#: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx
#: apps/remix/app/components/general/document/document-attachments-popover.tsx
@ -2153,6 +2165,10 @@ msgstr "Clear filters"
msgid "Clear Signature"
msgstr "Clear Signature"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Click here to add a recipient"
msgstr "Click here to add a recipient"
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
msgid "Click here to get started"
msgstr "Click here to get started"
@ -2275,6 +2291,7 @@ msgstr "Completed documents"
msgid "Completed Documents"
msgstr "Completed Documents"
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
msgid "Completed on {formattedDate}"
msgstr "Completed on {formattedDate}"
@ -2474,7 +2491,6 @@ msgid "Controls which signatures are allowed to be used when signing a document.
msgstr "Controls which signatures are allowed to be used when signing a document."
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "Copied"
msgstr "Copied"
@ -2492,14 +2508,12 @@ msgstr "Copied"
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
#: packages/ui/components/document/document-share-button.tsx
msgid "Copied to clipboard"
msgstr "Copied to clipboard"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "Copy"
msgstr "Copy"
@ -2517,6 +2531,7 @@ msgid "Copy Shareable Link"
msgstr "Copy Shareable Link"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Copy Signing Links"
msgstr "Copy Signing Links"
@ -3641,7 +3656,6 @@ msgstr "Drop your document here"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/lib/utils/fields.ts
msgid "Dropdown"
msgstr "Dropdown"
@ -4030,6 +4044,14 @@ msgstr "Envelope ID"
msgid "Envelope Item Count"
msgstr "Envelope Item Count"
#: packages/lib/utils/document-audit-logs.ts
msgid "Envelope item created"
msgstr "Envelope item created"
#: packages/lib/utils/document-audit-logs.ts
msgid "Envelope item deleted"
msgstr "Envelope item deleted"
#: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx
msgid "Envelope resent"
msgstr "Envelope resent"
@ -5539,7 +5561,6 @@ msgstr "No recipient matching this description was found."
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "No recipients"
msgstr "No recipients"
@ -7061,6 +7082,10 @@ msgstr "Select members or groups of members to add to the team."
msgid "Select members to add to this team"
msgstr "Select members to add to this team"
#: packages/lib/utils/fields.ts
msgid "Select Option"
msgstr "Select Option"
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
msgid "Select passkey"
msgstr "Select passkey"
@ -7865,6 +7890,15 @@ msgstr "Sync Email Domains"
msgid "Sync failed, changes not saved"
msgstr "Sync failed, changes not saved"
#: packages/lib/utils/document-audit-logs.ts
msgctxt "Audit log format"
msgid "System auto inserted fields"
msgstr "System auto inserted fields"
#: packages/lib/utils/document-audit-logs.ts
msgid "System auto inserted fields"
msgstr "System auto inserted fields"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "System Requirements"
msgstr "System Requirements"
@ -8417,7 +8451,6 @@ msgstr "The signer's name"
#: apps/remix/app/components/general/avatar-with-recipient.tsx
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "The signing link has been copied to your clipboard."
msgstr "The signing link has been copied to your clipboard."

View File

@ -262,10 +262,20 @@ msgstr "{prefix} agregó un campo"
msgid "{prefix} added a recipient"
msgstr "{prefix} agregó un destinatario"
#. placeholder {0}: data.envelopeItemTitle
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} created an envelope item with title {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} created the document"
msgstr "{prefix} creó el documento"
#. placeholder {0}: data.envelopeItemTitle
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} deleted an envelope item with title {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} deleted the document"
msgstr "{prefix} eliminó el documento"
@ -356,6 +366,7 @@ msgstr "{recipientActionVerb} documento"
msgid "{recipientActionVerb} the document to complete the process."
msgstr "{recipientActionVerb} el documento para completar el proceso."
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
msgid "{recipientCount} recipients"
msgstr "{recipientCount} destinatarios"
@ -1742,8 +1753,9 @@ msgstr ""
#: apps/remix/app/components/general/document/document-attachments-popover.tsx
msgid "Attachment removed successfully."
msgstr ""
msgstr "<<<<<<< Updated upstream======="
#: apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx
#: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx
#: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx
#: apps/remix/app/components/general/document/document-attachments-popover.tsx
@ -2158,6 +2170,10 @@ msgstr "Limpiar filtros"
msgid "Clear Signature"
msgstr "Limpiar firma"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Click here to add a recipient"
msgstr ""
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
msgid "Click here to get started"
msgstr "Haga clic aquí para comenzar"
@ -2280,6 +2296,7 @@ msgstr "Documentos completados"
msgid "Completed Documents"
msgstr "Documentos Completados"
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
msgid "Completed on {formattedDate}"
msgstr "Completado el {formattedDate}"
@ -2479,7 +2496,6 @@ msgid "Controls which signatures are allowed to be used when signing a document.
msgstr "Controla qué firmas están permitidas al firmar un documento."
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "Copied"
msgstr "Copiado"
@ -2497,14 +2513,12 @@ msgstr "Copiado"
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
#: packages/ui/components/document/document-share-button.tsx
msgid "Copied to clipboard"
msgstr "Copiado al portapapeles"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "Copy"
msgstr "Copiar"
@ -2522,6 +2536,7 @@ msgid "Copy Shareable Link"
msgstr "Copiar enlace compartible"
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Copy Signing Links"
msgstr "Copiar enlaces de firma"
@ -3646,7 +3661,6 @@ msgstr "Suelta tu documento aquí"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
#: packages/lib/utils/fields.ts
msgid "Dropdown"
msgstr "Menú desplegable"
@ -4035,6 +4049,14 @@ msgstr ""
msgid "Envelope Item Count"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "Envelope item created"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "Envelope item deleted"
msgstr ""
#: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx
msgid "Envelope resent"
msgstr ""
@ -5544,7 +5566,6 @@ msgstr "No se encontró ningún destinatario que coincidiera con esta descripci
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "No recipients"
msgstr "Sin destinatarios"
@ -7066,6 +7087,10 @@ msgstr "Seleccione miembros o grupos de miembros para agregar al equipo."
msgid "Select members to add to this team"
msgstr "Seleccione los miembros para añadir a este equipo"
#: packages/lib/utils/fields.ts
msgid "Select Option"
msgstr ""
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
msgid "Select passkey"
msgstr "Seleccionar clave de acceso"
@ -7870,6 +7895,15 @@ msgstr "Sincronizar dominios de correo electrónico"
msgid "Sync failed, changes not saved"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgctxt "Audit log format"
msgid "System auto inserted fields"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "System auto inserted fields"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "System Requirements"
msgstr "Requisitos del Sistema"
@ -8412,7 +8446,6 @@ msgstr "El nombre del firmante"
#: apps/remix/app/components/general/avatar-with-recipient.tsx
#: apps/remix/app/components/general/document/document-recipient-link-copy-dialog.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "The signing link has been copied to your clipboard."
msgstr "El enlace de firma ha sido copiado a tu portapapeles."

Some files were not shown because too many files have changed in this diff Show More