mirror of
https://github.com/documenso/documenso.git
synced 2025-11-09 20:12:31 +10:00
Compare commits
20 Commits
feat/allow
...
eff7d90f43
| Author | SHA1 | Date | |
|---|---|---|---|
| eff7d90f43 | |||
| db5524f8ce | |||
| 3d539b20ad | |||
| 48626b9169 | |||
| 88371b665a | |||
| 1650c55b19 | |||
| 60d73e0921 | |||
| 4a779ec81e | |||
| 7f19ec1265 | |||
| d6a2f5a4c9 | |||
| d05bfa9fed | |||
| d2a009d52e | |||
| 9350c53c7d | |||
| ffce7a2c81 | |||
| 353bdce86b | |||
| e13b9f7c84 | |||
| 9908580bf1 | |||
| b0b07106b4 | |||
| 35250fa308 | |||
| 5cdd7f8623 |
@ -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
692
CODE_STYLE.md
Normal 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
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -19,13 +19,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type DocumentDuplicateDialogProps = {
|
||||
id: number;
|
||||
id: string;
|
||||
token?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocumentDuplicateDialog = ({
|
||||
id,
|
||||
token,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentDuplicateDialogProps) => {
|
||||
@ -36,27 +38,23 @@ export const DocumentDuplicateDialog = ({
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { data: document, isLoading } = trpcReact.document.get.useQuery(
|
||||
{
|
||||
documentId: id,
|
||||
},
|
||||
{
|
||||
queryHash: `document-duplicate-dialog-${id}`,
|
||||
enabled: open === true,
|
||||
},
|
||||
);
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||
{
|
||||
envelopeId: id,
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const documentData = document?.documentData
|
||||
? {
|
||||
...document.documentData,
|
||||
data: document.documentData.initialData,
|
||||
}
|
||||
: undefined;
|
||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||
trpcReact.document.duplicate.useMutation({
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpcReact.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: _(msg`Document Duplicated`),
|
||||
@ -71,7 +69,7 @@ export const DocumentDuplicateDialog = ({
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateDocument({ documentId: id });
|
||||
await duplicateEnvelope({ envelopeId: id });
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
@ -83,14 +81,14 @@ export const DocumentDuplicateDialog = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Duplicate</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!documentData || isLoading ? (
|
||||
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
|
||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Loading Document...</Trans>
|
||||
@ -98,7 +96,12 @@ export const DocumentDuplicateDialog = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||
<PDFViewer key={document?.id} documentData={documentData} />
|
||||
<PDFViewer
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="original"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -115,8 +118,8 @@ export const DocumentDuplicateDialog = ({
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isDuplicateLoading || isLoading}
|
||||
loading={isDuplicateLoading}
|
||||
disabled={isDuplicating}
|
||||
loading={isDuplicating}
|
||||
onClick={onDuplicate}
|
||||
className="flex-1"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -2,11 +2,10 @@ import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -20,9 +19,7 @@ import {
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'title' | 'order'> & {
|
||||
documentData: DocumentData;
|
||||
};
|
||||
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
|
||||
|
||||
type EnvelopeDownloadDialogProps = {
|
||||
envelopeId: string;
|
||||
@ -64,12 +61,12 @@ export const EnvelopeDownloadDialog = ({
|
||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||
},
|
||||
{
|
||||
initialData: initialEnvelopeItems ? { envelopeItems: initialEnvelopeItems } : undefined,
|
||||
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
|
||||
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||
|
||||
const onDownload = async (
|
||||
envelopeItem: EnvelopeItemToDownload,
|
||||
@ -87,25 +84,11 @@ export const EnvelopeDownloadDialog = ({
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await getFile({
|
||||
type: envelopeItem.documentData.type,
|
||||
data:
|
||||
version === 'signed'
|
||||
? envelopeItem.documentData.data
|
||||
: envelopeItem.documentData.initialData,
|
||||
});
|
||||
|
||||
const blob = new Blob([data], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
|
||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||
const filename = `${baseTitle}${suffix}`;
|
||||
|
||||
downloadFile({
|
||||
filename,
|
||||
data: blob,
|
||||
await downloadPDF({
|
||||
envelopeItem,
|
||||
token,
|
||||
fileName: envelopeItem.title,
|
||||
version,
|
||||
});
|
||||
|
||||
setIsDownloadingState((prev) => ({
|
||||
@ -146,7 +129,7 @@ export const EnvelopeDownloadDialog = ({
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<>
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
{Array.from({ length: 1 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||
@ -175,6 +158,7 @@ export const EnvelopeDownloadDialog = ({
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Todo: Envelopes - Fix overflow */}
|
||||
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
<Trans>PDF Document</Trans>
|
||||
@ -213,8 +197,6 @@ export const EnvelopeDownloadDialog = ({
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Todo: Envelopes - Download all button */}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -43,7 +43,7 @@ export const EnvelopeDuplicateDialog = ({
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||
trpc.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ duplicatedEnvelopeId }) => {
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
title: t`Envelope Duplicated`,
|
||||
description: t`Your envelope has been successfully duplicated.`,
|
||||
@ -55,7 +55,7 @@ export const EnvelopeDuplicateDialog = ({
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${path}/${duplicatedEnvelopeId}/edit`);
|
||||
await navigate(`${path}/${id}/edit`);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
@ -185,6 +185,10 @@ export const OrganisationMemberInviteDialog = ({
|
||||
return 'form';
|
||||
}
|
||||
|
||||
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
|
||||
return 'form';
|
||||
}
|
||||
|
||||
// This is probably going to screw us over in the future.
|
||||
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
||||
return 'alert';
|
||||
|
||||
@ -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`),
|
||||
|
||||
@ -143,7 +143,7 @@ export function TemplateUseDialog({
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = response?.envelopeItems ?? [];
|
||||
const envelopeItems = response?.data ?? [];
|
||||
|
||||
const { mutateAsync: createDocumentFromTemplate } =
|
||||
trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
@ -4,6 +4,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentData, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { base64 } from '@scure/base';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@ -12,7 +13,6 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { base64 } from '@documenso/lib/universal/base64';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||
@ -83,21 +83,14 @@ export const ConfigureFieldsView = ({
|
||||
|
||||
const normalizedDocumentData = useMemo(() => {
|
||||
if (documentData) {
|
||||
return documentData;
|
||||
return documentData.data;
|
||||
}
|
||||
|
||||
if (!configData.documentData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = base64.encode(configData.documentData?.data);
|
||||
|
||||
return {
|
||||
id: 'preview',
|
||||
type: 'BYTES_64',
|
||||
data,
|
||||
initialData: data,
|
||||
} satisfies DocumentData;
|
||||
return base64.encode(configData.documentData.data);
|
||||
}, [configData.documentData]);
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
@ -541,7 +534,15 @@ export const ConfigureFieldsView = ({
|
||||
<Form {...form}>
|
||||
{normalizedDocumentData && (
|
||||
<div>
|
||||
<PDFViewer documentData={normalizedDocumentData} />
|
||||
<PDFViewer
|
||||
overrideData={normalizedDocumentData}
|
||||
envelopeItem={{
|
||||
id: '',
|
||||
envelopeId: '',
|
||||
}}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
/>
|
||||
|
||||
<ElementVisible
|
||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||
|
||||
@ -9,6 +9,7 @@ export type EmbedAuthenticationRequiredProps = {
|
||||
email?: string;
|
||||
returnTo: string;
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
oidcProviderLabel?: string;
|
||||
};
|
||||
@ -17,6 +18,7 @@ export const EmbedAuthenticationRequired = ({
|
||||
email,
|
||||
returnTo,
|
||||
// isGoogleSSOEnabled,
|
||||
// isMicrosoftSSOEnabled,
|
||||
// isOIDCSSOEnabled,
|
||||
// oidcProviderLabel,
|
||||
}: EmbedAuthenticationRequiredProps) => {
|
||||
@ -37,6 +39,7 @@ export const EmbedAuthenticationRequired = ({
|
||||
<SignInForm
|
||||
// Embed currently not supported.
|
||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
// oidcProviderLabel={oidcProviderLabel}
|
||||
className="mt-4"
|
||||
|
||||
@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useSearchParams } from 'react-router';
|
||||
@ -47,7 +47,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
token: string;
|
||||
envelopeId: string;
|
||||
updatedAt: Date;
|
||||
documentData: DocumentData;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | null;
|
||||
@ -59,7 +59,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
token,
|
||||
envelopeId,
|
||||
updatedAt,
|
||||
documentData,
|
||||
envelopeItems,
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
@ -335,7 +335,9 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<PDFViewer
|
||||
documentData={documentData}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -3,14 +3,8 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type Field,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
||||
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
@ -46,11 +40,11 @@ import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
import { EmbedDocumentFields } from './embed-document-fields';
|
||||
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||
|
||||
export type EmbedSignDocumentClientPageProps = {
|
||||
export type EmbedSignDocumentV1ClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
documentData: DocumentData;
|
||||
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
@ -61,11 +55,11 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
allRecipients?: RecipientWithFields[];
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
export const EmbedSignDocumentV1ClientPage = ({
|
||||
token,
|
||||
documentId,
|
||||
envelopeId,
|
||||
documentData,
|
||||
envelopeItems,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
@ -74,7 +68,7 @@ export const EmbedSignDocumentClientPage = ({
|
||||
hidePoweredBy = false,
|
||||
allowWhitelabelling = false,
|
||||
allRecipients = [],
|
||||
}: EmbedSignDocumentClientPageProps) => {
|
||||
}: EmbedSignDocumentV1ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -293,7 +287,9 @@ export const EmbedSignDocumentClientPage = ({
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<PDFViewer
|
||||
documentData={documentData}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
@ -0,0 +1,232 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
|
||||
import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
|
||||
import { useRequiredEnvelopeSigningContext } from '../general/document-signing/envelope-signing-provider';
|
||||
import { EmbedClientLoading } from './embed-client-loading';
|
||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||
import { EmbedSigningProvider } from './embed-signing-context';
|
||||
|
||||
export type EmbedSignDocumentV2ClientPageProps = {
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentV2ClientPage = ({
|
||||
hidePoweredBy = false,
|
||||
allowWhitelabelling = false,
|
||||
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { envelope, recipient, envelopeData, setFullName, fullName } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { isCompleted, isRejected, recipientSignature } = envelopeData;
|
||||
|
||||
// !: Not used at the moment, may be removed in the future.
|
||||
// const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
|
||||
const onDocumentCompleted = (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
}) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-completed',
|
||||
data,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentError = () => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-error',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentReady = () => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-ready',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldSigned = (data: { fieldId?: number; value?: string; isBase64?: boolean }) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'field-signed',
|
||||
data,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldUnsigned = (data: { fieldId?: number }) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'field-unsigned',
|
||||
data,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentRejected = (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-rejected',
|
||||
data,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
try {
|
||||
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||
|
||||
if (!isCompleted && data.name) {
|
||||
setFullName(data.name);
|
||||
}
|
||||
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (allowWhitelabelling) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setHasFinishedInit(true);
|
||||
|
||||
// !: While the setters are stable we still want to ensure we're avoiding
|
||||
// !: re-renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allowWhitelabelling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFinishedInit) {
|
||||
onDocumentReady();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasFinishedInit]);
|
||||
|
||||
// Listen for document completion events from the envelope signing context
|
||||
useEffect(() => {
|
||||
if (isCompleted) {
|
||||
onDocumentCompleted({
|
||||
token: recipient.token,
|
||||
envelopeId: envelope.id,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
}
|
||||
}, [isCompleted, envelope.id, recipient.id, recipient.token]);
|
||||
|
||||
// Listen for document rejection events
|
||||
useEffect(() => {
|
||||
if (isRejected) {
|
||||
onDocumentRejected({
|
||||
token: recipient.token,
|
||||
envelopeId: envelope.id,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
}
|
||||
}, [isRejected, envelope.id, recipient.id, recipient.token]);
|
||||
|
||||
if (isRejected) {
|
||||
return <EmbedDocumentRejected />;
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<EmbedDocumentCompleted
|
||||
name={fullName}
|
||||
signature={
|
||||
recipientSignature
|
||||
? {
|
||||
id: 1,
|
||||
fieldId: 1,
|
||||
recipientId: recipient.id,
|
||||
created: new Date(),
|
||||
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
|
||||
typedSignature: recipientSignature.typedSignature,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmbedSigningProvider
|
||||
isNameLocked={isNameLocked}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowDocumentRejection={allowDocumentRejection}
|
||||
onDocumentCompleted={onDocumentCompleted}
|
||||
onDocumentError={onDocumentError}
|
||||
onDocumentRejected={onDocumentRejected}
|
||||
onDocumentReady={onDocumentReady}
|
||||
onFieldSigned={onFieldSigned}
|
||||
onFieldUnsigned={onFieldUnsigned}
|
||||
>
|
||||
<div className="embed--Root relative">
|
||||
{!hasFinishedInit && <EmbedClientLoading />}
|
||||
|
||||
<DocumentSigningPageViewV2 />
|
||||
</div>
|
||||
</EmbedSigningProvider>
|
||||
);
|
||||
};
|
||||
101
apps/remix/app/components/embed/embed-signing-context.tsx
Normal file
101
apps/remix/app/components/embed/embed-signing-context.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type EmbedSigningContextValue = {
|
||||
isEmbed: true;
|
||||
allowDocumentRejection: boolean;
|
||||
isNameLocked: boolean;
|
||||
isEmailLocked: boolean;
|
||||
hidePoweredBy: boolean;
|
||||
onDocumentCompleted: (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
}) => void;
|
||||
onDocumentError: () => void;
|
||||
onDocumentRejected: (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
reason?: string;
|
||||
}) => void;
|
||||
onDocumentReady: () => void;
|
||||
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||
};
|
||||
|
||||
const EmbedSigningContext = createContext<EmbedSigningContextValue | null>(null);
|
||||
|
||||
export const useEmbedSigningContext = () => {
|
||||
return useContext(EmbedSigningContext);
|
||||
};
|
||||
|
||||
export const useRequiredEmbedSigningContext = () => {
|
||||
const context = useEmbedSigningContext();
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useRequiredEmbedSigningContext must be used within EmbedSigningProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type EmbedSigningProviderProps = {
|
||||
allowDocumentRejection?: boolean;
|
||||
isNameLocked?: boolean;
|
||||
isEmailLocked?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
onDocumentCompleted: (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
}) => void;
|
||||
onDocumentError: () => void;
|
||||
onDocumentRejected: (data: {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
reason?: string;
|
||||
}) => void;
|
||||
onDocumentReady: () => void;
|
||||
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const EmbedSigningProvider = ({
|
||||
allowDocumentRejection = false,
|
||||
isNameLocked = false,
|
||||
isEmailLocked = true,
|
||||
hidePoweredBy = false,
|
||||
onDocumentCompleted,
|
||||
onDocumentError,
|
||||
onDocumentRejected,
|
||||
onDocumentReady,
|
||||
onFieldSigned,
|
||||
onFieldUnsigned,
|
||||
children,
|
||||
}: EmbedSigningProviderProps) => {
|
||||
return (
|
||||
<EmbedSigningContext.Provider
|
||||
value={{
|
||||
isEmbed: true,
|
||||
allowDocumentRejection,
|
||||
isNameLocked,
|
||||
isEmailLocked,
|
||||
hidePoweredBy,
|
||||
onDocumentCompleted,
|
||||
onDocumentError,
|
||||
onDocumentRejected,
|
||||
onDocumentReady,
|
||||
onFieldSigned,
|
||||
onFieldUnsigned,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EmbedSigningContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -226,7 +226,9 @@ export const MultiSignDocumentSigningView = ({
|
||||
})}
|
||||
>
|
||||
<PDFViewer
|
||||
documentData={document.documentData}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
onDocumentLoad={() => {
|
||||
setHasDocumentLoaded(true);
|
||||
onDocumentReady?.();
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -29,6 +29,8 @@ import {
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
@ -68,6 +70,9 @@ export function BrandingPreferencesForm({
|
||||
}: BrandingPreferencesFormProps) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||
|
||||
@ -88,14 +93,13 @@ export function BrandingPreferencesForm({
|
||||
const file = JSON.parse(settings.brandingLogo);
|
||||
|
||||
if ('type' in file && 'data' in file) {
|
||||
void getFile(file).then((binaryData) => {
|
||||
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
|
||||
const logoUrl =
|
||||
context === 'Team'
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||
|
||||
setPreviewUrl(objectUrl);
|
||||
setHasLoadedPreview(true);
|
||||
});
|
||||
|
||||
return;
|
||||
setPreviewUrl(logoUrl);
|
||||
setHasLoadedPreview(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -152,6 +152,18 @@ export const EditorFieldTextForm = ({
|
||||
className="h-auto"
|
||||
placeholder={t`Add text to the field`}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const values = form.getValues();
|
||||
const characterLimit = values.characterLimit || 0;
|
||||
let textValue = e.target.value;
|
||||
|
||||
if (characterLimit > 0 && textValue.length > characterLimit) {
|
||||
textValue = textValue.slice(0, characterLimit);
|
||||
}
|
||||
|
||||
e.target.value = textValue;
|
||||
field.onChange(e);
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -175,6 +187,18 @@ export const EditorFieldTextForm = ({
|
||||
className="bg-background"
|
||||
placeholder={t`Field character limit`}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
|
||||
const values = form.getValues();
|
||||
const characterLimit = parseInt(e.target.value, 10) || 0;
|
||||
|
||||
const textValue = values.text || '';
|
||||
|
||||
if (characterLimit > 0 && textValue.length > characterLimit) {
|
||||
form.setValue('text', textValue.slice(0, characterLimit));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@ -92,6 +92,7 @@ export const SignInForm = ({
|
||||
|
||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||
useState(false);
|
||||
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||
|
||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||
'totp' | 'backup'
|
||||
@ -317,6 +318,8 @@ export const SignInForm = ({
|
||||
if (email) {
|
||||
form.setValue('email', email);
|
||||
}
|
||||
|
||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
@ -383,56 +386,64 @@ export const SignInForm = ({
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
|
||||
{hasSocialAuthEnabled && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<Trans>Or continue with</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
)}
|
||||
{!isEmbeddedRedirect && (
|
||||
<>
|
||||
{hasSocialAuthEnabled && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<Trans>Or continue with</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGoogleSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
)}
|
||||
{isGoogleSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithGoogleClick}
|
||||
>
|
||||
<FcGoogle className="mr-2 h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithMicrosoftClick}
|
||||
>
|
||||
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
|
||||
Microsoft
|
||||
</Button>
|
||||
)}
|
||||
{isMicrosoftSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithMicrosoftClick}
|
||||
>
|
||||
<img
|
||||
className="mr-2 h-4 w-4"
|
||||
alt="Microsoft Logo"
|
||||
src={'/static/microsoft.svg'}
|
||||
/>
|
||||
Microsoft
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isOIDCSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
{oidcProviderLabel || 'OIDC'}
|
||||
</Button>
|
||||
{isOIDCSSOEnabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||
{oidcProviderLabel || 'OIDC'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
|
||||
@ -68,6 +68,7 @@ export type SignUpFormProps = {
|
||||
isGoogleSSOEnabled?: boolean;
|
||||
isMicrosoftSSOEnabled?: boolean;
|
||||
isOIDCSSOEnabled?: boolean;
|
||||
returnTo?: string;
|
||||
};
|
||||
|
||||
export const SignUpForm = ({
|
||||
@ -76,6 +77,7 @@ export const SignUpForm = ({
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
returnTo,
|
||||
}: SignUpFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -110,7 +112,7 @@ export const SignUpForm = ({
|
||||
signature,
|
||||
});
|
||||
|
||||
await navigate(`/unverified-account`);
|
||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||
|
||||
toast({
|
||||
title: _(msg`Registration Successful`),
|
||||
|
||||
@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Theme, useTheme } from 'remix-themes';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import {
|
||||
@ -63,10 +64,12 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const debouncedSearch = useDebouncedValue(search, 200);
|
||||
|
||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||
trpcReact.document.search.useQuery(
|
||||
{
|
||||
query: search,
|
||||
query: debouncedSearch,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
@ -232,6 +235,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
)}
|
||||
|
||||
{!currentPage && (
|
||||
<>
|
||||
{documentPageLinks.length > 0 && (
|
||||
@ -239,14 +243,17 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
<Commands push={push} pages={documentPageLinks} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{templatePageLinks.length > 0 && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
||||
<Commands push={push} pages={templatePageLinks} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
|
||||
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
|
||||
Change language
|
||||
@ -255,6 +262,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
Change theme
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
||||
<Commands push={push} pages={searchResults} />
|
||||
|
||||
@ -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,
|
||||
@ -149,7 +153,9 @@ export const DirectTemplatePageView = ({
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
key={template.id}
|
||||
documentData={template.templateDocumentData}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={directTemplateRecipient.token}
|
||||
version="signed"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
|
||||
actionVerb = 'sign',
|
||||
onOpenChange,
|
||||
}: DocumentSigningAuthAccountProps) => {
|
||||
const { recipient } = useRequiredDocumentSigningAuthContext();
|
||||
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
@ -34,8 +34,10 @@ export const DocumentSigningAuthAccount = ({
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
|
||||
await authClient.signOut({
|
||||
redirectPath: `/signin#email=${email}`,
|
||||
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||
});
|
||||
} catch {
|
||||
setIsSigningOut(false);
|
||||
@ -55,16 +57,28 @@ export const DocumentSigningAuthAccount = ({
|
||||
<AlertDescription>
|
||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||
<span>
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{/* Todo: Translate */}
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
||||
in as <strong>{recipient.email}</strong>
|
||||
{isDirectTemplate ? (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||
logged in as <strong>{recipient.email}</strong>
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
|
||||
@ -47,7 +47,8 @@ export const DocumentSigningAuthDialog = ({
|
||||
onOpenChange,
|
||||
onReauthFormSubmit,
|
||||
}: DocumentSigningAuthDialogProps) => {
|
||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
||||
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
|
||||
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
||||
const validAuthTypes = availableAuthTypes.filter(
|
||||
@ -168,7 +169,11 @@ export const DocumentSigningAuthDialog = ({
|
||||
match({ documentAuthType: selectedAuthType, user })
|
||||
.with(
|
||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
||||
{
|
||||
user: P.when(
|
||||
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
|
||||
),
|
||||
}, // Assume all current auth methods requires them to be logged in.
|
||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||
)
|
||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>;
|
||||
@ -37,6 +40,7 @@ export type DocumentSigningAuthContextValue = {
|
||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||
isAuthRedirectRequired: boolean;
|
||||
isDirectTemplate?: boolean;
|
||||
isCurrentlyAuthenticating: boolean;
|
||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||
passkeyData: PasskeyData;
|
||||
@ -65,6 +69,7 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
||||
export interface DocumentSigningAuthProviderProps {
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: SigningAuthRecipient;
|
||||
isDirectTemplate?: boolean;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@ -72,6 +77,7 @@ export interface DocumentSigningAuthProviderProps {
|
||||
export const DocumentSigningAuthProvider = ({
|
||||
documentAuthOptions: initialDocumentAuthOptions,
|
||||
recipient: initialRecipient,
|
||||
isDirectTemplate = false,
|
||||
user,
|
||||
children,
|
||||
}: DocumentSigningAuthProviderProps) => {
|
||||
@ -201,6 +207,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
derivedRecipientAccessAuth,
|
||||
derivedRecipientActionAuth,
|
||||
isAuthRedirectRequired,
|
||||
isDirectTemplate,
|
||||
isCurrentlyAuthenticating,
|
||||
setIsCurrentlyAuthenticating,
|
||||
passkeyData,
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||
|
||||
@ -102,6 +103,8 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||
|
||||
const form = useForm<TNextSignerFormSchema>({
|
||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||
defaultValues: {
|
||||
@ -267,7 +270,12 @@ export const DocumentSigningCompleteDialog = ({
|
||||
<Trans>Your Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} className="mt-2" placeholder={t`Enter your name`} />
|
||||
<Input
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder={t`Enter your name`}
|
||||
disabled={isNameLocked}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@ -289,6 +297,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
type="email"
|
||||
className="mt-2"
|
||||
placeholder={t`Enter your email`}
|
||||
disabled={!!field.value && isEmailLocked}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -304,7 +313,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}
|
||||
|
||||
@ -8,6 +8,9 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
|
||||
import { BrandingLogo } from '../branding-logo';
|
||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
|
||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||
@ -15,6 +18,8 @@ import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||
export const DocumentSigningMobileWidget = () => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
|
||||
|
||||
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
@ -29,7 +34,7 @@ export const DocumentSigningMobileWidget = () => {
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
|
||||
<div className="pointer-events-auto w-full max-w-2xl">
|
||||
<div className="pointer-events-auto w-full max-w-[760px]">
|
||||
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
|
||||
{/* Main Header Bar */}
|
||||
<div className="flex items-center justify-between gap-4 p-4">
|
||||
@ -114,6 +119,13 @@ export const DocumentSigningMobileWidget = () => {
|
||||
{isExpanded && (
|
||||
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
|
||||
<EnvelopeSignerForm />
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground mt-2 inline-block rounded px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:hidden">
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -245,7 +245,12 @@ export const DocumentSigningPageViewV1 = ({
|
||||
<div className="flex-1">
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||
<PDFViewer
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={recipient.token}
|
||||
version="signed"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -22,7 +22,9 @@ import { SignFieldNameDialog } from '~/components/dialogs/sign-field-name-dialog
|
||||
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
|
||||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
|
||||
import { BrandingLogo } from '../branding-logo';
|
||||
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||
@ -48,6 +50,13 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
selectedAssistantRecipientFields,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
const {
|
||||
isEmbed = false,
|
||||
allowDocumentRejection = true,
|
||||
hidePoweredBy = true,
|
||||
onDocumentRejected,
|
||||
} = useEmbedSigningContext() || {};
|
||||
|
||||
/**
|
||||
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
||||
*
|
||||
@ -75,9 +84,9 @@ 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="embed--DocumentWidgetContainer 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">
|
||||
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||
{match(recipient.role)
|
||||
@ -107,7 +116,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
||||
<EnvelopeSignerForm />
|
||||
</div>
|
||||
</div>
|
||||
@ -116,17 +125,21 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
|
||||
{/* Quick Actions. */}
|
||||
{!isDirectTemplate && (
|
||||
<div className="space-y-3 px-4">
|
||||
<div className="embed--Actions space-y-3 px-4">
|
||||
<h4 className="text-foreground text-sm font-semibold">
|
||||
<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}
|
||||
@ -141,10 +154,21 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && (
|
||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
onRejected={
|
||||
onDocumentRejected &&
|
||||
((reason) =>
|
||||
onDocumentRejected({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
reason,
|
||||
}))
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -160,18 +184,22 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer of left sidebar. */}
|
||||
<div className="mt-auto px-4">
|
||||
<Button asChild variant="ghost" className="w-full justify-start">
|
||||
<Link to="/">
|
||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="embed--DocumentWidgetFooter">
|
||||
{/* Footer of left sidebar. */}
|
||||
{!isEmbed && (
|
||||
<div className="mt-auto px-4">
|
||||
<Button asChild variant="ghost" className="w-full justify-start">
|
||||
<Link to="/">
|
||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Return</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{envelopeItems.length > 1 && (
|
||||
@ -198,11 +226,11 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
)}
|
||||
|
||||
{/* Document View */}
|
||||
<div className="flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||
{currentEnvelopeItem ? (
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="signing"
|
||||
key={currentEnvelopeItem.id}
|
||||
documentDataId={currentEnvelopeItem.documentDataId}
|
||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||
/>
|
||||
) : (
|
||||
@ -214,9 +242,20 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
)}
|
||||
|
||||
{/* Mobile widget - Additional padding to allow users to scroll */}
|
||||
<div className="block pb-16 md:hidden">
|
||||
<div className="block pb-28 lg:hidden">
|
||||
<DocumentSigningMobileWidget />
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<a
|
||||
href="https://documenso.com"
|
||||
target="_blank"
|
||||
className="bg-primary text-primary-foreground fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:block"
|
||||
>
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,10 +8,12 @@ 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';
|
||||
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
isFieldUnsignedAndRequired,
|
||||
isRequiredField,
|
||||
@ -50,7 +52,11 @@ export type EnvelopeSigningContextValue = {
|
||||
setSelectedAssistantRecipientId: (_value: number | null) => void;
|
||||
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
|
||||
|
||||
signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise<void>;
|
||||
signField: (
|
||||
_fieldId: number,
|
||||
_value: TSignEnvelopeFieldValue,
|
||||
authOptions?: TRecipientActionAuth,
|
||||
) => Promise<Pick<Field, 'id' | 'inserted'>>;
|
||||
};
|
||||
|
||||
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
|
||||
@ -165,7 +171,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]);
|
||||
|
||||
/**
|
||||
@ -261,21 +289,26 @@ export const EnvelopeSigningProvider = ({
|
||||
: null;
|
||||
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
|
||||
|
||||
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
|
||||
console.log('insertField', fieldId, fieldValue);
|
||||
|
||||
const signField = async (
|
||||
fieldId: number,
|
||||
fieldValue: TSignEnvelopeFieldValue,
|
||||
authOptions?: TRecipientActionAuth,
|
||||
) => {
|
||||
// Set the field locally for direct templates.
|
||||
if (isDirectTemplate) {
|
||||
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
||||
return;
|
||||
const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
||||
|
||||
return signedField;
|
||||
}
|
||||
|
||||
await signEnvelopeField({
|
||||
const { signedField } = await signEnvelopeField({
|
||||
token: envelopeData.recipient.token,
|
||||
fieldId,
|
||||
fieldValue,
|
||||
authOptions: undefined,
|
||||
authOptions,
|
||||
});
|
||||
|
||||
return signedField;
|
||||
};
|
||||
|
||||
const handleDirectTemplateFieldInsertion = (
|
||||
@ -333,6 +366,8 @@ export const EnvelopeSigningProvider = ({
|
||||
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
||||
},
|
||||
}));
|
||||
|
||||
return updatedField;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
||||
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
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';
|
||||
@ -19,9 +23,10 @@ import {
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
|
||||
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
||||
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
|
||||
import { ShareDocumentDownloadButton } from '../share-document-download-button';
|
||||
|
||||
export type DocumentCertificateQRViewProps = {
|
||||
documentId: number;
|
||||
@ -31,6 +36,7 @@ export type DocumentCertificateQRViewProps = {
|
||||
documentTeamUrl: string;
|
||||
recipientCount?: number;
|
||||
completedDate?: Date;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const DocumentCertificateQRView = ({
|
||||
@ -41,6 +47,7 @@ export const DocumentCertificateQRView = ({
|
||||
documentTeamUrl,
|
||||
recipientCount = 0,
|
||||
completedDate,
|
||||
token,
|
||||
}: DocumentCertificateQRViewProps) => {
|
||||
const { data: documentViaUser } = trpc.document.get.useQuery({
|
||||
documentId,
|
||||
@ -92,6 +99,76 @@ export const DocumentCertificateQRView = ({
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{internalVersion === 2 ? (
|
||||
<EnvelopeRenderProvider envelope={{ envelopeItems }} token={token}>
|
||||
<DocumentCertificateQrV2
|
||||
title={title}
|
||||
recipientCount={recipientCount}
|
||||
formattedDate={formattedDate}
|
||||
token={token}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelopeItems[0].envelopeId}
|
||||
envelopeStatus={DocumentStatus.COMPLETED}
|
||||
envelopeItems={envelopeItems}
|
||||
token={token}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" className="flex-1">
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 w-full">
|
||||
<PDFViewer
|
||||
key={envelopeItems[0].id}
|
||||
envelopeItem={envelopeItems[0]}
|
||||
token={token}
|
||||
version="signed"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DocumentCertificateQrV2Props = {
|
||||
title: string;
|
||||
recipientCount: number;
|
||||
formattedDate: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
const DocumentCertificateQrV2 = ({
|
||||
title,
|
||||
recipientCount,
|
||||
formattedDate,
|
||||
token,
|
||||
}: DocumentCertificateQrV2Props) => {
|
||||
const { currentEnvelopeItem, envelopeItems } = 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 +183,24 @@ export const DocumentCertificateQRView = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareDocumentDownloadButton title={title} documentData={envelopeItems[0].documentData} />
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelopeItems[0].envelopeId}
|
||||
envelopeStatus={DocumentStatus.COMPLETED}
|
||||
envelopeItems={envelopeItems}
|
||||
token={token}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" className="flex-1">
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</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 renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -441,9 +441,10 @@ export const DocumentEditForm = ({
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
key={document.documentData.id}
|
||||
documentData={document.documentData}
|
||||
document={document}
|
||||
key={document.envelopeItems[0].id}
|
||||
envelopeItem={document.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@ -1,18 +1,14 @@
|
||||
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 { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
|
||||
@ -23,9 +19,6 @@ export type DocumentPageViewButtonProps = {
|
||||
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
|
||||
const { user } = useSession();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
const isRecipient = !!recipient;
|
||||
@ -37,25 +30,6 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
||||
const documentsPath = formatDocumentsPath(envelope.team.url);
|
||||
const formatPath = `${documentsPath}/${envelope.id}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
// Todo; Envelopes - Support multiple items
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
if (!envelopeItem.documentData) {
|
||||
throw new Error('No document available');
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return match({
|
||||
isRecipient,
|
||||
isPending,
|
||||
@ -95,7 +69,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: true, internalVersion: 2 }, () => (
|
||||
.with({ isComplete: true }, () => (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
@ -109,11 +83,5 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
||||
}
|
||||
/>
|
||||
))
|
||||
.with({ isComplete: true }, () => (
|
||||
<Button className="w-full" onClick={onDownloadClick}>
|
||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => null);
|
||||
};
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
@ -16,13 +15,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -67,64 +64,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.get.query(
|
||||
{
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
},
|
||||
{
|
||||
context: {
|
||||
teamId: team?.id?.toString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const documentData = documentWithData?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: envelope.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadOriginalClick = async () => {
|
||||
try {
|
||||
const documentWithData = await trpcClient.document.get.query(
|
||||
{
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
},
|
||||
{
|
||||
context: {
|
||||
teamId: team?.id?.toString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const documentData = documentWithData?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: envelope.title, version: 'original' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
@ -147,36 +86,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{envelope.internalVersion === 2 ? (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
token={recipient?.token}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isComplete && (
|
||||
<DropdownMenuItem onClick={onDownloadClick}>
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
token={recipient?.token}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
||||
@ -250,7 +173,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DocumentDuplicateDialog
|
||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
id={envelope.id}
|
||||
token={recipient?.token}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -26,7 +26,7 @@ import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
const { t, i18n } = useLingui();
|
||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
|
||||
const interactiveTransformer = useRef<Transformer | null>(null);
|
||||
|
||||
@ -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) {
|
||||
@ -105,7 +103,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
fieldUpdates.height = fieldPageHeight;
|
||||
}
|
||||
|
||||
// Todo: envelopes Use id
|
||||
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
|
||||
|
||||
// Select the field if it is not already selected.
|
||||
@ -116,7 +113,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
pageLayer.current?.batchDraw();
|
||||
};
|
||||
|
||||
const renderFieldOnLayer = (field: TLocalField) => {
|
||||
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
|
||||
if (!pageLayer.current) {
|
||||
return;
|
||||
}
|
||||
@ -162,6 +159,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
fieldGroup.on('dragend', handleResizeOrMove);
|
||||
};
|
||||
|
||||
const renderFieldOnLayer = (field: TLocalField) => {
|
||||
try {
|
||||
unsafeRenderFieldOnLayer(field);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the Konva page canvas and all fields and interactions.
|
||||
*/
|
||||
@ -273,9 +279,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;
|
||||
|
||||
@ -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,12 +21,14 @@ import type {
|
||||
TNameFieldMeta,
|
||||
TNumberFieldMeta,
|
||||
TRadioFieldMeta,
|
||||
TSignatureFieldMeta,
|
||||
TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
@ -37,6 +40,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 +65,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
};
|
||||
|
||||
export const EnvelopeEditorFieldsPage = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
@ -104,14 +108,39 @@ 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 flex-col items-center justify-center">
|
||||
{envelope.recipients.length === 0 && (
|
||||
<Alert
|
||||
variant="neutral"
|
||||
className="border-border bg-background mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<AlertTitle>
|
||||
<Trans>Missing Recipients</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>You need at least one recipient to add fields</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`${relativePath.editorPath}`}>
|
||||
<Trans>Add Recipients</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||
@ -127,31 +156,23 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{currentEnvelopeItem && envelope.recipients.length > 0 && (
|
||||
<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">
|
||||
<Trans>Selected Recipient</Trans>
|
||||
</h3>
|
||||
|
||||
{envelope.recipients.length === 0 ? (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<Trans>You need at least one recipient to add fields</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<RecipientSelector
|
||||
selectedRecipient={editorFields.selectedRecipient}
|
||||
onSelectedRecipientChange={(recipient) =>
|
||||
editorFields.setSelectedRecipient(recipient.id)
|
||||
}
|
||||
recipients={envelope.recipients}
|
||||
className="w-full"
|
||||
align="end"
|
||||
/>
|
||||
)}
|
||||
<RecipientSelector
|
||||
selectedRecipient={editorFields.selectedRecipient}
|
||||
onSelectedRecipientChange={(recipient) =>
|
||||
editorFields.setSelectedRecipient(recipient.id)
|
||||
}
|
||||
recipients={envelope.recipients}
|
||||
className="w-full"
|
||||
align="end"
|
||||
/>
|
||||
|
||||
{editorFields.selectedRecipient &&
|
||||
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
|
||||
@ -182,7 +203,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 +213,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}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
import { lazy, useEffect, useState } from 'react';
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { faker } from '@faker-js/faker/locale/en';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { ConstructionIcon, FileTextIcon } from 'lucide-react';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
EnvelopeRenderProvider,
|
||||
useCurrentEnvelopeRender,
|
||||
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
@ -15,15 +25,169 @@ import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
|
||||
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
||||
|
||||
// Todo: Envelopes - Dynamically import faker
|
||||
export const EnvelopeEditorPreviewPage = () => {
|
||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||
|
||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||
'recipient',
|
||||
);
|
||||
|
||||
const fieldsWithPlaceholders = useMemo(() => {
|
||||
return fields.map((field) => {
|
||||
const fieldMeta = ZFieldAndMetaSchema.parse(field);
|
||||
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
faker.seed(recipient.id);
|
||||
|
||||
const recipientName = recipient.name || faker.person.fullName();
|
||||
const recipientEmail = recipient.email || faker.internet.email();
|
||||
|
||||
faker.seed(recipient.id + field.id);
|
||||
|
||||
return {
|
||||
...field,
|
||||
inserted: true,
|
||||
...match(fieldMeta)
|
||||
.with({ type: FieldType.TEXT }, ({ fieldMeta }) => {
|
||||
let text = fieldMeta?.text || faker.lorem.words(5);
|
||||
|
||||
if (fieldMeta?.characterLimit) {
|
||||
text = text.slice(0, fieldMeta?.characterLimit);
|
||||
}
|
||||
|
||||
return {
|
||||
customText: text,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.NUMBER }, ({ fieldMeta }) => {
|
||||
let number = fieldMeta?.value ?? '';
|
||||
|
||||
if (number === '') {
|
||||
number = faker.number
|
||||
.int({
|
||||
min: fieldMeta?.minValue ?? 0,
|
||||
max: fieldMeta?.maxValue ?? 1000,
|
||||
})
|
||||
.toString();
|
||||
}
|
||||
|
||||
return {
|
||||
customText: number,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.DATE }, () => {
|
||||
const date = extractFieldInsertionValues({
|
||||
fieldValue: {
|
||||
type: FieldType.DATE,
|
||||
value: true,
|
||||
},
|
||||
field,
|
||||
documentMeta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
return {
|
||||
customText: date.customText,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.EMAIL }, () => {
|
||||
return {
|
||||
customText: recipientEmail,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.NAME }, () => {
|
||||
return {
|
||||
customText: recipientName,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.INITIALS }, () => {
|
||||
return {
|
||||
customText: extractInitials(recipientName),
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.RADIO }, ({ fieldMeta }) => {
|
||||
const values = fieldMeta?.values ?? [];
|
||||
|
||||
if (values.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let customText = '';
|
||||
|
||||
const preselectedValue = values.findIndex((value) => value.checked);
|
||||
|
||||
if (preselectedValue !== -1) {
|
||||
customText = preselectedValue.toString();
|
||||
} else {
|
||||
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
|
||||
customText = randomIndex.toString();
|
||||
}
|
||||
|
||||
return {
|
||||
customText,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.CHECKBOX }, ({ fieldMeta }) => {
|
||||
let checkedValues: number[] = [];
|
||||
|
||||
const values = fieldMeta?.values ?? [];
|
||||
|
||||
values.forEach((value, index) => {
|
||||
if (value.checked) {
|
||||
checkedValues.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
if (checkedValues.length === 0 && values.length > 0) {
|
||||
const numberOfValues = fieldMeta?.validationLength || 1;
|
||||
|
||||
checkedValues = Array.from({ length: numberOfValues }, (_, index) => index);
|
||||
}
|
||||
|
||||
return {
|
||||
customText: toCheckboxCustomText(checkedValues),
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.DROPDOWN }, ({ fieldMeta }) => {
|
||||
const values = fieldMeta?.values ?? [];
|
||||
|
||||
let customText = fieldMeta?.defaultValue || '';
|
||||
|
||||
if (!customText && values.length > 0) {
|
||||
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
|
||||
customText = values[randomIndex].value;
|
||||
}
|
||||
|
||||
return {
|
||||
customText,
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.SIGNATURE }, () => {
|
||||
return {
|
||||
customText: '',
|
||||
signature: {
|
||||
signatureImageAsBase64: '',
|
||||
typedSignature: recipientName,
|
||||
},
|
||||
};
|
||||
})
|
||||
.with({ type: FieldType.FREE_SIGNATURE }, () => {
|
||||
return {
|
||||
customText: '',
|
||||
};
|
||||
})
|
||||
.exhaustive(),
|
||||
};
|
||||
});
|
||||
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
*/
|
||||
@ -31,40 +195,38 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
|
||||
}, []);
|
||||
|
||||
// Override the parent renderer provider so we can inject custom fields.
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col">
|
||||
{/* Horizontal envelope item selector */}
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
<EnvelopeRenderProvider
|
||||
envelope={envelope}
|
||||
token={undefined}
|
||||
fields={fieldsWithPlaceholders}
|
||||
recipients={envelope.recipients}
|
||||
overrideSettings={{
|
||||
mode: 'export',
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-full">
|
||||
<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 flex-col items-center justify-center">
|
||||
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/* Document View */}
|
||||
<div className="mt-4 flex flex-col items-center justify-center">
|
||||
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Coming soon section */}
|
||||
<div className="border-border bg-card hover:bg-accent/10 flex w-full max-w-[800px] items-center gap-4 rounded-lg border p-4 transition-colors">
|
||||
<div className="flex w-full flex-col items-center justify-center gap-2 py-32">
|
||||
<ConstructionIcon className="text-muted-foreground h-10 w-10" />
|
||||
<h3 className="text-foreground text-sm font-semibold">
|
||||
<Trans>Coming soon</Trans>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>This feature is coming soon</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Todo: Envelopes - Remove div after preview mode is implemented */}
|
||||
<div className="hidden">
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="editor"
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||
@ -78,27 +240,28 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Add fields section. */}
|
||||
<section className="px-4">
|
||||
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||
{/* Right Section - Form Fields Panel */}
|
||||
{currentEnvelopeItem && false && (
|
||||
<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">
|
||||
<Trans>Preivew Mode</Trans>
|
||||
</h3> */}
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Alert variant="neutral">
|
||||
<AlertTitle>
|
||||
<Trans>Preview Mode</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Preview what the signed document will look like with placeholder data
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* <Alert variant="neutral">
|
||||
{/* <Alert variant="neutral">
|
||||
<RadioGroup
|
||||
className="gap-y-1"
|
||||
value={selectedPreviewMode}
|
||||
@ -137,36 +300,37 @@ export const EnvelopeEditorPreviewPage = () => {
|
||||
<div>Preview what a recipient will see</div>
|
||||
|
||||
<div>Preview the signed document</div> */}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{false && (
|
||||
<AnimateGenericFadeInOut key={selectedPreviewMode}>
|
||||
{selectedPreviewMode === 'recipient' && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
{false && (
|
||||
<AnimateGenericFadeInOut key={selectedPreviewMode}>
|
||||
{selectedPreviewMode === 'recipient' && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* Recipient selector section. */}
|
||||
<section className="px-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||
<Trans>Selected Recipient</Trans>
|
||||
</h3>
|
||||
{/* Recipient selector section. */}
|
||||
<section className="px-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||
<Trans>Selected Recipient</Trans>
|
||||
</h3>
|
||||
|
||||
<RecipientSelector
|
||||
selectedRecipient={editorFields.selectedRecipient}
|
||||
onSelectedRecipientChange={(recipient) =>
|
||||
editorFields.setSelectedRecipient(recipient.id)
|
||||
}
|
||||
recipients={envelope.recipients}
|
||||
className="w-full"
|
||||
align="end"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RecipientSelector
|
||||
selectedRecipient={editorFields.selectedRecipient}
|
||||
onSelectedRecipientChange={(recipient) =>
|
||||
editorFields.setSelectedRecipient(recipient.id)
|
||||
}
|
||||
recipients={envelope.recipients}
|
||||
className="w-full"
|
||||
align="end"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</EnvelopeRenderProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,64 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
|
||||
...formValues,
|
||||
signers: recipients,
|
||||
signers: nonEmptyRecipients,
|
||||
});
|
||||
|
||||
if (validatedFormValues.success) {
|
||||
console.log('validatedFormValues', validatedFormValues);
|
||||
if (!validatedFormValues.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRecipientsDebounced(validatedFormValues.data.signers);
|
||||
const { data } = validatedFormValues;
|
||||
|
||||
if (
|
||||
validatedFormValues.data.signingOrder !== envelope.documentMeta.signingOrder ||
|
||||
validatedFormValues.data.allowDictateNextSigner !==
|
||||
envelope.documentMeta.allowDictateNextSigner
|
||||
) {
|
||||
updateEnvelope({
|
||||
meta: {
|
||||
signingOrder: validatedFormValues.data.signingOrder,
|
||||
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
|
||||
},
|
||||
});
|
||||
// Weird edge case where the whole envelope is created via API
|
||||
// with no signing order. If they come to this page it will show an error
|
||||
// since they aren't equal and the recipient is no longer editable.
|
||||
const envelopeRecipients = data.signers.map((recipient) => {
|
||||
if (!canRecipientBeModified(recipient.id)) {
|
||||
return {
|
||||
...recipient,
|
||||
signingOrder: recipient.signingOrder,
|
||||
};
|
||||
}
|
||||
return recipient;
|
||||
});
|
||||
|
||||
const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
|
||||
const hasAllowDictateNextSignerChanged =
|
||||
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
|
||||
|
||||
const hasSignersChanged =
|
||||
envelopeRecipients.length !== recipients.length ||
|
||||
envelopeRecipients.some((signer) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === signer.id);
|
||||
|
||||
if (!recipient) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const signerActionAuth = signer.actionAuth;
|
||||
const recipientActionAuth = recipient.authOptions?.actionAuth || [];
|
||||
|
||||
return (
|
||||
signer.email !== recipient.email ||
|
||||
signer.name !== recipient.name ||
|
||||
signer.role !== recipient.role ||
|
||||
signer.signingOrder !== recipient.signingOrder ||
|
||||
!isDeepEqual(signerActionAuth, recipientActionAuth)
|
||||
);
|
||||
});
|
||||
|
||||
if (hasSignersChanged) {
|
||||
setRecipientsDebounced(envelopeRecipients);
|
||||
}
|
||||
|
||||
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
|
||||
updateEnvelope({
|
||||
meta: {
|
||||
signingOrder: validatedFormValues.data.signingOrder,
|
||||
allowDictateNextSigner: validatedFormValues.data.allowDictateNextSigner,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [formValues]);
|
||||
|
||||
@ -534,17 +571,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 +739,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 +788,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 +918,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 +933,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>
|
||||
)}
|
||||
|
||||
@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { envelope } = useCurrentEnvelopeEditor();
|
||||
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
@ -186,14 +186,12 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
documentAuth: envelope.authOptions,
|
||||
});
|
||||
|
||||
const form = useForm<TAddSettingsFormSchema>({
|
||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||
defaultValues: {
|
||||
externalId: envelope.externalId || '', // Todo: String or undefined?
|
||||
const createDefaultValues = () => {
|
||||
return {
|
||||
externalId: envelope.externalId || '',
|
||||
visibility: envelope.visibility || '',
|
||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
|
||||
globalActionAuth: documentAuthOption?.globalActionAuth || [],
|
||||
|
||||
meta: {
|
||||
subject: envelope.documentMeta.subject ?? '',
|
||||
message: envelope.documentMeta.message ?? '',
|
||||
@ -210,10 +208,13 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
|
||||
const form = useForm<TAddSettingsFormSchema>({
|
||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||
defaultValues: createDefaultValues(),
|
||||
});
|
||||
|
||||
const envelopeHasBeenSent =
|
||||
envelope.type === EnvelopeType.DOCUMENT &&
|
||||
@ -229,7 +230,6 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
// Todo: Envelopes this doesn't make sense (look at previous)
|
||||
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
|
||||
|
||||
const onFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||
@ -240,9 +240,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
.safeParse(data.globalAccessAuth);
|
||||
|
||||
try {
|
||||
await updateEnvelope({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
await updateEnvelopeAsync({
|
||||
data: {
|
||||
externalId: data.externalId || null,
|
||||
visibility: data.visibility,
|
||||
@ -297,7 +295,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
form.reset(createDefaultValues());
|
||||
setActiveTab('general');
|
||||
}, [open, form]);
|
||||
|
||||
@ -323,7 +321,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
|
||||
{/* Sidebar. */}
|
||||
<div className="flex w-80 flex-col border-r bg-gray-50">
|
||||
<div className="bg-accent/20 flex w-80 flex-col border-r">
|
||||
<DialogHeader className="p-6 pb-4">
|
||||
<DialogTitle>Document Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
@ -355,7 +353,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}
|
||||
>
|
||||
|
||||
@ -18,9 +18,9 @@ import {
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Card,
|
||||
@ -67,8 +67,8 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
|
||||
trpc.envelope.item.createMany.useMutation({
|
||||
onSuccess: (data) => {
|
||||
const createdEnvelopes = data.createdEnvelopeItems.filter(
|
||||
onSuccess: ({ data }) => {
|
||||
const createdEnvelopes = data.filter(
|
||||
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
|
||||
);
|
||||
|
||||
@ -79,10 +79,10 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
});
|
||||
|
||||
const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({
|
||||
onSuccess: (data) => {
|
||||
onSuccess: ({ data }) => {
|
||||
setLocalEnvelope({
|
||||
envelopeItems: envelope.envelopeItems.map((originalItem) => {
|
||||
const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id);
|
||||
const updatedItem = data.find((item) => item.id === originalItem.id);
|
||||
|
||||
if (updatedItem) {
|
||||
return {
|
||||
@ -114,36 +114,19 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
||||
|
||||
const result = await Promise.all(
|
||||
files.map(async (file, index) => {
|
||||
try {
|
||||
const response = await putPdfFile(file);
|
||||
|
||||
// Mark as uploaded (remove from uploading state)
|
||||
return {
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
};
|
||||
} catch (_error) {
|
||||
setLocalFiles((prev) =>
|
||||
prev.map((uploadingFile) =>
|
||||
uploadingFile.id === newUploadingFiles[index].id
|
||||
? { ...uploadingFile, isError: true, isUploading: false }
|
||||
: uploadingFile,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const envelopeItemsToCreate = result.filter(
|
||||
(item): item is { title: string; documentDataId: string } => item !== undefined,
|
||||
);
|
||||
|
||||
const { createdEnvelopeItems } = await createEnvelopeItems({
|
||||
const payload = {
|
||||
envelopeId: envelope.id,
|
||||
items: envelopeItemsToCreate,
|
||||
}).catch((error) => {
|
||||
} satisfies TCreateEnvelopeItemsPayload;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
const { data } = await createEnvelopeItems(formData).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
// Set error state on files in batch upload.
|
||||
@ -165,7 +148,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
);
|
||||
|
||||
return filteredFiles.concat(
|
||||
createdEnvelopeItems.map((item) => ({
|
||||
data.map((item) => ({
|
||||
id: item.id,
|
||||
envelopeItemId: item.id,
|
||||
title: item.title,
|
||||
@ -203,7 +186,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
debouncedUpdateEnvelopeItems(items);
|
||||
};
|
||||
|
||||
// Todo: Envelopes - Sync into envelopes data
|
||||
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
|
||||
void updateEnvelopeItems({
|
||||
envelopeId: envelope.id,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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'
|
||||
@ -28,7 +29,7 @@ export const EnvelopeItemSelector = ({
|
||||
{...buttonProps}
|
||||
>
|
||||
<div
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${
|
||||
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
||||
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
@ -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}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||
import type Konva from 'konva';
|
||||
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
@ -8,11 +9,23 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
||||
|
||||
type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||
};
|
||||
|
||||
export default function EnvelopeGenericPageRenderer() {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||
const {
|
||||
currentEnvelopeItem,
|
||||
fields,
|
||||
recipients,
|
||||
getRecipientColorKey,
|
||||
setRenderError,
|
||||
overrideSettings,
|
||||
} = useCurrentEnvelopeRender();
|
||||
|
||||
const {
|
||||
stage,
|
||||
@ -28,45 +41,73 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
|
||||
const { _className, scale } = pageContext;
|
||||
|
||||
const localPageFields = useMemo(
|
||||
() =>
|
||||
fields.filter(
|
||||
const localPageFields = useMemo((): GenericLocalField[] => {
|
||||
return fields
|
||||
.filter(
|
||||
(field) =>
|
||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||
),
|
||||
[fields, pageContext.pageNumber],
|
||||
);
|
||||
)
|
||||
.map((field) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
||||
if (!recipient) {
|
||||
throw new Error(`Recipient not found for field ${field.id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
recipient,
|
||||
};
|
||||
});
|
||||
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||
|
||||
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
const { recipient } = field;
|
||||
|
||||
const fieldTranslations = getClientSideFieldTranslations(i18n);
|
||||
|
||||
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
|
||||
|
||||
renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
field: {
|
||||
renderId: field.id.toString(),
|
||||
...field,
|
||||
customText: '',
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
inserted: false,
|
||||
customText: isInserted ? field.customText : '',
|
||||
fieldMeta: field.fieldMeta,
|
||||
signature: {
|
||||
signatureImageAsBase64: '',
|
||||
typedSignature: fieldTranslations.SIGNATURE,
|
||||
},
|
||||
},
|
||||
translations: getClientSideFieldTranslations(i18n),
|
||||
translations: fieldTranslations,
|
||||
pageWidth: unscaledViewport.width,
|
||||
pageHeight: unscaledViewport.height,
|
||||
// color: getRecipientColorKey(field.recipientId),
|
||||
color: 'purple', // Todo
|
||||
color: getRecipientColorKey(field.recipientId),
|
||||
editable: false,
|
||||
mode: 'sign',
|
||||
mode: overrideSettings?.mode ?? 'sign',
|
||||
});
|
||||
};
|
||||
|
||||
const renderFieldOnLayer = (field: GenericLocalField) => {
|
||||
try {
|
||||
unsafeRenderFieldOnLayer(field);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the Konva page canvas and all fields and interactions.
|
||||
*/
|
||||
@ -80,7 +121,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 +134,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);
|
||||
});
|
||||
|
||||
@ -116,6 +155,16 @@ export default function EnvelopeGenericPageRenderer() {
|
||||
className="relative w-full"
|
||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||
>
|
||||
{overrideSettings?.showRecipientTooltip &&
|
||||
localPageFields.map((field) => (
|
||||
<EnvelopeRecipientFieldTooltip
|
||||
key={field.id}
|
||||
field={field}
|
||||
showFieldStatus={overrideSettings?.showRecipientSigningStatus}
|
||||
showRecipientTooltip={overrideSettings?.showRecipientTooltip}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* The element Konva will inject it's canvas into. */}
|
||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ import { Label } from '@documenso/ui/primitives/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
|
||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||
|
||||
export default function EnvelopeSignerForm() {
|
||||
@ -25,6 +27,8 @@ export default function EnvelopeSignerForm() {
|
||||
setSelectedAssistantRecipientId,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||
|
||||
const hasSignatureField = useMemo(() => {
|
||||
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
}, [recipientFields]);
|
||||
@ -37,7 +41,7 @@ export default function EnvelopeSignerForm() {
|
||||
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
return (
|
||||
<fieldset className="dark:bg-background border-border rounded-2xl sm:border sm:p-3">
|
||||
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
|
||||
<RadioGroup
|
||||
className="gap-0 space-y-2 shadow-none sm:space-y-3"
|
||||
value={selectedAssistantRecipient?.id?.toString()}
|
||||
@ -101,7 +105,8 @@ export default function EnvelopeSignerForm() {
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
disabled={isNameLocked}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
|
||||
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||
@ -28,7 +29,7 @@ export const EnvelopeSignerHeader = () => {
|
||||
useRequiredEnvelopeSigningContext();
|
||||
|
||||
return (
|
||||
<nav className="bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
|
||||
<nav className="embed--DocumentWidgetHeader bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
|
||||
{/* Left side - Logo and title */}
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
|
||||
<Link to="/" className="flex-shrink-0">
|
||||
@ -72,7 +73,7 @@ export const EnvelopeSignerHeader = () => {
|
||||
</div>
|
||||
|
||||
{/* Right side - Desktop content */}
|
||||
<div className="hidden items-center space-x-2 md:flex">
|
||||
<div className="hidden items-center space-x-2 lg:flex">
|
||||
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
||||
<Plural
|
||||
one="1 Field Remaining"
|
||||
@ -85,7 +86,7 @@ export const EnvelopeSignerHeader = () => {
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions button */}
|
||||
<div className="flex-shrink-0 md:hidden">
|
||||
<div className="flex-shrink-0 lg:hidden">
|
||||
<MobileDropdownMenu />
|
||||
</div>
|
||||
</nav>
|
||||
@ -95,6 +96,8 @@ export const EnvelopeSignerHeader = () => {
|
||||
const MobileDropdownMenu = () => {
|
||||
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { allowDocumentRejection } = useEmbedSigningContext() || {};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@ -119,7 +122,7 @@ const MobileDropdownMenu = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{envelope.type === EnvelopeType.DOCUMENT && (
|
||||
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
|
||||
<DocumentSigningRejectDialog
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
token={recipient.token}
|
||||
|
||||
@ -10,15 +10,20 @@ import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-rende
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
||||
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
|
||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
|
||||
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
|
||||
import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
|
||||
@ -28,20 +33,24 @@ import { handleNumberFieldClick } from '~/utils/field-signing/number-field';
|
||||
import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field';
|
||||
import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
|
||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||
|
||||
export default function EnvelopeSignerPageRenderer() {
|
||||
const { i18n } = useLingui();
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
const { t, i18n } = useLingui();
|
||||
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
|
||||
const { sessionData } = useOptionalSession();
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
envelopeData,
|
||||
recipient,
|
||||
recipientFields,
|
||||
recipientFieldsRemaining,
|
||||
showPendingFieldTooltip,
|
||||
signField,
|
||||
signField: signFieldInternal,
|
||||
email,
|
||||
setEmail,
|
||||
fullName,
|
||||
@ -53,6 +62,8 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
isDirectTemplate,
|
||||
} = useRequiredEnvelopeSigningContext();
|
||||
|
||||
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
||||
|
||||
const {
|
||||
stage,
|
||||
pageLayer,
|
||||
@ -80,7 +91,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
);
|
||||
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
|
||||
|
||||
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
if (!pageLayer.current) {
|
||||
console.error('Layer not loaded yet');
|
||||
return;
|
||||
@ -237,7 +248,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload); // Todo: Envelopes - Handle errors
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
|
||||
if (payload?.value) {
|
||||
@ -318,7 +329,6 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
* SIGNATURE FIELD.
|
||||
*/
|
||||
.with({ type: FieldType.SIGNATURE }, (field) => {
|
||||
// Todo: Envelopes - Reauth
|
||||
handleSignatureFieldClick({
|
||||
field,
|
||||
signature,
|
||||
@ -329,11 +339,21 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
.then(async (payload) => {
|
||||
if (payload) {
|
||||
fieldGroup.add(loadingSpinnerGroup);
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
|
||||
if (payload?.value) {
|
||||
setSignature(payload.value);
|
||||
if (payload.value) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => {
|
||||
await signField(field.id, payload, authOptions);
|
||||
|
||||
loadingSpinnerGroup.destroy();
|
||||
},
|
||||
actionTarget: field.type,
|
||||
});
|
||||
|
||||
setSignature(payload.value);
|
||||
} else {
|
||||
await signField(field.id, payload);
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@ -347,13 +367,54 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
fieldGroup.on('pointerdown', handleFieldGroupClick);
|
||||
};
|
||||
|
||||
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
||||
try {
|
||||
unsafeRenderFieldOnLayer(unparsedField);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setRenderError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const signField = async (
|
||||
fieldId: number,
|
||||
payload: TSignEnvelopeFieldValue,
|
||||
authOptions?: TRecipientActionAuth,
|
||||
) => {
|
||||
try {
|
||||
const { inserted } = await signFieldInternal(fieldId, payload, authOptions);
|
||||
|
||||
// ?: The two callbacks below are used within the embedding context
|
||||
if (inserted && onFieldSigned) {
|
||||
const value = payload.value ? JSON.stringify(payload.value) : undefined;
|
||||
const isBase64 = value ? isBase64Image(value) : undefined;
|
||||
|
||||
onFieldSigned({ fieldId, value, isBase64 });
|
||||
}
|
||||
|
||||
if (!inserted && onFieldUnsigned) {
|
||||
onFieldUnsigned({ fieldId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while signing the field.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the Konva page canvas and all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||
renderFieldOnLayer(field);
|
||||
}
|
||||
|
||||
currentPageLayer.batchDraw();
|
||||
@ -368,8 +429,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
}
|
||||
|
||||
localPageFields.forEach((field) => {
|
||||
console.log('Field changed/inserted, rendering on canvas');
|
||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
@ -387,7 +447,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
pageLayer.current.destroyChildren();
|
||||
|
||||
localPageFields.forEach((field) => {
|
||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
|
||||
@ -2,16 +2,19 @@ import { useMemo } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||
|
||||
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
|
||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||
|
||||
@ -19,8 +22,9 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@ -37,6 +41,8 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
|
||||
|
||||
const { mutateAsync: completeDocument, isPending } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
@ -68,25 +74,54 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
nextSigner?: { name: string; email: string },
|
||||
accessAuthOptions?: TRecipientAccessAuth,
|
||||
) => {
|
||||
const payload = {
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
authOptions: accessAuthOptions,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
};
|
||||
try {
|
||||
const payload = {
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
authOptions: accessAuthOptions,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
};
|
||||
|
||||
await completeDocument(payload);
|
||||
await completeDocument(payload);
|
||||
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: envelope.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
analytics.capture('App: Recipient has completed signing', {
|
||||
signerId: recipient.id,
|
||||
documentId: envelope.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (envelope.documentMeta.redirectUrl) {
|
||||
window.location.href = envelope.documentMeta.redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${recipient.token}/complete`);
|
||||
if (onDocumentCompleted) {
|
||||
onDocumentCompleted({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.documentMeta.redirectUrl) {
|
||||
window.location.href = envelope.documentMeta.redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${recipient.token}/complete`);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@ -105,8 +140,12 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||
}
|
||||
|
||||
if (!recipient.directToken) {
|
||||
throw new Error('Recipient direct token is required');
|
||||
}
|
||||
|
||||
const { token } = await createDocumentFromDirectTemplate({
|
||||
directTemplateToken: recipient.token, // The direct template token is inserted into the recipient token for ease of use.
|
||||
directTemplateToken: recipient.directToken, // The direct template token is inserted into the recipient token for ease of use.
|
||||
directTemplateExternalId,
|
||||
directRecipientName: recipientDetails?.name || fullName,
|
||||
directRecipientEmail: recipientDetails?.email || email,
|
||||
@ -127,22 +166,36 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
isBase64,
|
||||
};
|
||||
}),
|
||||
nextSigner,
|
||||
});
|
||||
|
||||
const redirectUrl = envelope.documentMeta.redirectUrl;
|
||||
|
||||
if (onDocumentCompleted) {
|
||||
await navigate({
|
||||
pathname: `/embed/sign/${token}`,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
} else {
|
||||
await navigate(`/sign/${token}/complete`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('err', err);
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import type { DocumentData } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type ShareDocumentDownloadButtonProps = {
|
||||
title: string;
|
||||
documentData: DocumentData;
|
||||
};
|
||||
|
||||
// Todo: Envelopes - Support multiple item downloads.
|
||||
export const ShareDocumentDownloadButton = ({
|
||||
title,
|
||||
documentData,
|
||||
}: ShareDocumentDownloadButtonProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
|
||||
await downloadPDF({ documentData, fileName: title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button loading={isDownloading} onClick={onDownloadClick}>
|
||||
{!isDownloading && <Download className="mr-2 h-4 w-4" />}
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@ -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`),
|
||||
|
||||
@ -313,8 +313,10 @@ export const TemplateEditForm = ({
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer
|
||||
key={templateDocumentData.id}
|
||||
documentData={templateDocumentData}
|
||||
key={template.envelopeItems[0].id}
|
||||
envelopeItem={template.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@ -1,19 +1,14 @@
|
||||
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 { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@ -25,8 +20,6 @@ export type DocumentsTableActionButtonProps = {
|
||||
|
||||
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
||||
const { user } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
@ -44,39 +37,6 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const document = !recipient
|
||||
? await trpcClient.document.get.query(
|
||||
{
|
||||
documentId: row.id,
|
||||
},
|
||||
{
|
||||
context: {
|
||||
teamId: team?.id?.toString(),
|
||||
},
|
||||
},
|
||||
)
|
||||
: await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
throw Error('No document available');
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: row.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||
return null;
|
||||
@ -134,7 +94,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
<Trans>View</Trans>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: true, internalVersion: 2 }, () => (
|
||||
.with({ isComplete: true }, () => (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeStatus={row.status}
|
||||
@ -147,11 +107,5 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
}
|
||||
/>
|
||||
))
|
||||
.with({ isComplete: true }, () => (
|
||||
<Button className="w-32" onClick={onDownloadClick}>
|
||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
))
|
||||
.otherwise(() => <div></div>);
|
||||
};
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
Download,
|
||||
Edit,
|
||||
EyeIcon,
|
||||
FileDown,
|
||||
FolderInput,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
@ -20,12 +19,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -34,7 +31,6 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||
@ -56,7 +52,6 @@ export const DocumentsTableActionDropdown = ({
|
||||
const { user } = useSession();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@ -76,58 +71,6 @@ export const DocumentsTableActionDropdown = ({
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const document = !recipient
|
||||
? await trpcClient.document.get.query({
|
||||
documentId: row.id,
|
||||
})
|
||||
: await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: row.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadOriginalClick = async () => {
|
||||
try {
|
||||
const document = !recipient
|
||||
? await trpcClient.document.get.query({
|
||||
documentId: row.id,
|
||||
})
|
||||
: await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: row.title, version: 'original' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
@ -178,33 +121,19 @@ export const DocumentsTableActionDropdown = ({
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{row.internalVersion === 2 ? (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeStatus={row.status}
|
||||
token={recipient?.token}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeStatus={row.status}
|
||||
token={recipient?.token}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
@ -273,7 +202,8 @@ export const DocumentsTableActionDropdown = ({
|
||||
/>
|
||||
|
||||
<DocumentDuplicateDialog
|
||||
id={row.id}
|
||||
id={row.envelopeId}
|
||||
token={recipient?.token}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
|
||||
@ -10,11 +10,9 @@ import { DateTime } from 'luxon';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -28,6 +26,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||
|
||||
export type DocumentsTableProps = {
|
||||
@ -199,28 +198,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
||||
return null;
|
||||
}
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
const document = await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
throw Error('No document available');
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: row.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`An error occurred while downloading your document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||
return null;
|
||||
@ -230,6 +207,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
internalVersion: row.internalVersion,
|
||||
})
|
||||
.with({ isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
@ -263,10 +241,17 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: true }, () => (
|
||||
<Button className="w-32" onClick={onDownloadClick}>
|
||||
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeStatus={row.status}
|
||||
token={recipient?.token}
|
||||
trigger={
|
||||
<Button className="w-32">
|
||||
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))
|
||||
.otherwise(() => <div></div>);
|
||||
};
|
||||
|
||||
@ -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 />}
|
||||
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -147,7 +147,13 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||
<EnvelopeRenderProvider
|
||||
envelope={envelope}
|
||||
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
overrideSettings={{
|
||||
showRecipientSigningStatus: true,
|
||||
showRecipientTooltip: true,
|
||||
}}
|
||||
>
|
||||
{isMultiEnvelopeItem && (
|
||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||
@ -155,7 +161,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="preview"
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</EnvelopeRenderProvider>
|
||||
@ -177,9 +186,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
)}
|
||||
|
||||
<PDFViewer
|
||||
document={envelope}
|
||||
envelopeItem={envelope.envelopeItems[0]}
|
||||
token={undefined}
|
||||
key={envelope.envelopeItems[0].id}
|
||||
documentData={envelope.envelopeItems[0].documentData}
|
||||
version="signed"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -99,7 +99,12 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||
<EnvelopeRenderProvider envelope={envelope}>
|
||||
<EnvelopeRenderProvider
|
||||
envelope={envelope}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
>
|
||||
<EnvelopeEditor />
|
||||
</EnvelopeRenderProvider>
|
||||
</EnvelopeEditorProvider>
|
||||
|
||||
@ -168,14 +168,25 @@ 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}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
recipients={envelope.recipients}
|
||||
overrideSettings={{
|
||||
showRecipientTooltip: true,
|
||||
}}
|
||||
>
|
||||
{isMultiEnvelopeItem && (
|
||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||
)}
|
||||
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
||||
<PDFViewerKonvaLazy
|
||||
renderer="preview"
|
||||
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</EnvelopeRenderProvider>
|
||||
@ -196,9 +207,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
/>
|
||||
|
||||
<PDFViewer
|
||||
document={envelope}
|
||||
envelopeItem={envelope.envelopeItems[0]}
|
||||
token={undefined}
|
||||
version="signed"
|
||||
key={envelope.envelopeItems[0].id}
|
||||
documentData={envelope.envelopeItems[0].documentData}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -188,6 +184,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={directTemplateRecipient}
|
||||
isDirectTemplate={true}
|
||||
user={user}
|
||||
>
|
||||
<>
|
||||
@ -226,20 +223,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}
|
||||
>
|
||||
@ -248,7 +246,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope}>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<DocumentSigningPageViewV2 />
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
|
||||
@ -492,7 +492,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope}>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<DocumentSigningPageViewV2 />
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { CheckCircle2, Clock8, FileSearch } from 'lucide-react';
|
||||
import { CheckCircle2, Clock8, DownloadIcon } from 'lucide-react';
|
||||
import { Link, useRevalidator } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -20,14 +19,13 @@ import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-emai
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { ClaimAccount } from '~/components/general/claim-account';
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
|
||||
@ -207,24 +205,16 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||
|
||||
{isDocumentCompleted(document.status) ? (
|
||||
<DocumentDownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
documentData={document.documentData}
|
||||
disabled={!isDocumentCompleted(document.status)}
|
||||
/>
|
||||
) : (
|
||||
<DocumentDialog
|
||||
documentData={document.documentData}
|
||||
{isDocumentCompleted(document.status) && (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={document.envelopeId}
|
||||
envelopeStatus={document.status}
|
||||
envelopeItems={document.envelopeItems}
|
||||
token={recipient?.token}
|
||||
trigger={
|
||||
<Button
|
||||
className="text-[11px]"
|
||||
title={_(msg`Signatures will appear once the document has been completed`)}
|
||||
variant="outline"
|
||||
>
|
||||
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
|
||||
<Trans>View Original Document</Trans>
|
||||
<Button type="button" variant="outline" className="flex-1">
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -60,6 +60,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
|
||||
|
||||
return {
|
||||
document,
|
||||
token: slug,
|
||||
};
|
||||
}
|
||||
|
||||
@ -74,7 +75,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
|
||||
};
|
||||
|
||||
export default function SharePage() {
|
||||
const { document } = useLoaderData<typeof loader>();
|
||||
const { document, token } = useLoaderData<typeof loader>();
|
||||
|
||||
if (document) {
|
||||
return (
|
||||
@ -86,6 +87,7 @@ export default function SharePage() {
|
||||
envelopeItems={document.envelopeItems}
|
||||
recipientCount={document.recipientCount}
|
||||
completedDate={document.completedAt ?? undefined}
|
||||
token={token}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
@ -9,6 +11,7 @@ import {
|
||||
OIDC_PROVIDER_LABEL,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@ -28,8 +31,12 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||
|
||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||
|
||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||
|
||||
if (isAuthenticated) {
|
||||
throw redirect('/');
|
||||
throw redirect(returnTo || '/');
|
||||
}
|
||||
|
||||
return {
|
||||
@ -37,12 +44,28 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
||||
loaderData;
|
||||
const {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
returnTo,
|
||||
} = loaderData;
|
||||
|
||||
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-screen max-w-lg px-4">
|
||||
@ -61,13 +84,17 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
oidcProviderLabel={oidcProviderLabel}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
|
||||
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
<Trans>
|
||||
Don't have an account?{' '}
|
||||
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
|
||||
<Link
|
||||
to={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
|
||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Trans>
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
IS_OIDC_SSO_ENABLED,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@ -16,7 +17,7 @@ export function meta() {
|
||||
return appMetaTags('Sign Up');
|
||||
}
|
||||
|
||||
export function loader() {
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||
|
||||
// SSR env variables.
|
||||
@ -28,15 +29,20 @@ export function loader() {
|
||||
throw redirect('/signin');
|
||||
}
|
||||
|
||||
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||
|
||||
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||
|
||||
return {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
returnTo,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
|
||||
|
||||
return (
|
||||
<SignUpForm
|
||||
@ -44,6 +50,7 @@ export default function SignUp({ loaderData }: Route.ComponentProps) {
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,11 +2,14 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
|
||||
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
IS_OIDC_SSO_ENABLED,
|
||||
OIDC_PROVIDER_LABEL,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
|
||||
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
||||
import { EmbedDocumentCompleted } from '~/components/embed/embed-document-completed';
|
||||
import { EmbedDocumentRejected } from '~/components/embed/embed-document-rejected';
|
||||
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
|
||||
import { EmbedPaywall } from '~/components/embed/embed-paywall';
|
||||
|
||||
@ -29,11 +32,13 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
|
||||
export function loader() {
|
||||
// SSR env variables.
|
||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||
|
||||
return {
|
||||
isGoogleSSOEnabled,
|
||||
isMicrosoftSSOEnabled,
|
||||
isOIDCSSOEnabled,
|
||||
oidcProviderLabel,
|
||||
};
|
||||
@ -44,15 +49,19 @@ export default function Layout() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
||||
loaderData || {};
|
||||
|
||||
const error = useRouteError();
|
||||
|
||||
console.log({ routeError: error });
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
||||
return (
|
||||
<EmbedAuthenticationRequired
|
||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
oidcProviderLabel={oidcProviderLabel}
|
||||
email={error.data.email}
|
||||
@ -68,6 +77,16 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||
if (error.status === 403 && error.data.type === 'embed-waiting-for-turn') {
|
||||
return <EmbedDocumentWaitingForTurn />;
|
||||
}
|
||||
|
||||
// !: Not used at the moment, may be removed in the future.
|
||||
if (error.status === 403 && error.data.type === 'embed-document-rejected') {
|
||||
return <EmbedDocumentRejected />;
|
||||
}
|
||||
|
||||
// !: Not used at the moment, may be removed in the future.
|
||||
if (error.status === 403 && error.data.type === 'embed-document-completed') {
|
||||
return <EmbedDocumentCompleted name={error.data.name} signature={error.data.signature} />;
|
||||
}
|
||||
}
|
||||
|
||||
return <div>Not Found</div>;
|
||||
|
||||
332
apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
Normal file
332
apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
import { data } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
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 { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
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';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
|
||||
import { EmbedSignDocumentV2ClientPage } from '~/components/embed/embed-document-signing-page-v2';
|
||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
|
||||
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/direct.$token';
|
||||
|
||||
async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
||||
if (!params.token) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.token;
|
||||
|
||||
const template = await getTemplateByDirectLinkToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
// `template.directLink` is always available but we're doing this to
|
||||
// satisfy the type checker.
|
||||
if (!template || !template.directLink) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: template.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
// TODO: Make this more robust, we need to ensure the owner is either
|
||||
// TODO: the member of a team that has an active subscription, is an early
|
||||
// TODO: adopter or is an enterprise user.
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
||||
match(auth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { directTemplateRecipientId } = template.directLink;
|
||||
|
||||
const recipient = template.recipients.find(
|
||||
(recipient) => recipient.id === directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
template,
|
||||
recipient,
|
||||
fields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||
if (!params.token) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.token;
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const envelopeForSigning = await getEnvelopeForDirectTemplateSigning({
|
||||
token,
|
||||
userId: user?.id,
|
||||
})
|
||||
.then((envelopeForSigning) => {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
...envelopeForSigning,
|
||||
} as const;
|
||||
})
|
||||
.catch(async (e) => {
|
||||
const error = AppError.parseError(e);
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
...requiredAccessData,
|
||||
} as const;
|
||||
}
|
||||
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
});
|
||||
|
||||
if (!envelopeForSigning.isDocumentAccessValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: envelopeForSigning.recipientEmail,
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { envelope, recipient } = envelopeForSigning;
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||
match(accesssAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email || recipient.email,
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
envelopeForSigning,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
const { token } = loaderArgs.params;
|
||||
|
||||
if (!token) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Not efficient but works for now until we remove v1.
|
||||
const foundDirectLink = await prisma.templateDirectLink.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
envelope: {
|
||||
select: {
|
||||
internalVersion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundDirectLink) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (foundDirectLink.envelope.internalVersion === 2) {
|
||||
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 2,
|
||||
payload: payloadV2,
|
||||
} as const);
|
||||
}
|
||||
|
||||
const payloadV1 = await handleV1Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 1,
|
||||
payload: payloadV1,
|
||||
} as const);
|
||||
}
|
||||
|
||||
export default function EmbedDirectTemplatePage() {
|
||||
const { version, payload } = useSuperLoaderData<typeof loader>();
|
||||
|
||||
if (version === 1) {
|
||||
return <EmbedDirectTemplatePageV1 data={payload} />;
|
||||
}
|
||||
|
||||
return <EmbedDirectTemplatePageV2 data={payload} />;
|
||||
}
|
||||
|
||||
const EmbedDirectTemplatePageV1 = ({
|
||||
data,
|
||||
}: {
|
||||
data: Awaited<ReturnType<typeof handleV1Loader>>;
|
||||
}) => {
|
||||
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
||||
data;
|
||||
|
||||
return (
|
||||
<DocumentSigningProvider
|
||||
email={user?.email}
|
||||
fullName={user?.name}
|
||||
signature={user?.signature}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||
<EmbedDirectTemplateClientPage
|
||||
token={token}
|
||||
envelopeId={template.envelopeId}
|
||||
updatedAt={template.updatedAt}
|
||||
envelopeItems={template.envelopeItems}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhiteLabelling={allowEmbedSigningWhitelabel}
|
||||
/>
|
||||
</DocumentSigningRecipientProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const EmbedDirectTemplatePageV2 = ({
|
||||
data,
|
||||
}: {
|
||||
data: Awaited<ReturnType<typeof handleV2Loader>>;
|
||||
}) => {
|
||||
const { token, user, envelopeForSigning, hidePoweredBy, allowEmbedSigningWhitelabel } = data;
|
||||
|
||||
const { envelope, recipient } = envelopeForSigning;
|
||||
|
||||
return (
|
||||
<EnvelopeSigningProvider
|
||||
envelopeData={envelopeForSigning}
|
||||
email={user?.email}
|
||||
fullName={user?.name}
|
||||
signature={user?.signature}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={envelope.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
isDirectTemplate={true}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
/>
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
</EnvelopeSigningProvider>
|
||||
);
|
||||
};
|
||||
@ -1,138 +0,0 @@
|
||||
import { data } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
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';
|
||||
|
||||
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
|
||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/direct.$url';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
if (!params.url) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.url;
|
||||
|
||||
const template = await getTemplateByDirectLinkToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
// `template.directLink` is always available but we're doing this to
|
||||
// satisfy the type checker.
|
||||
if (!template || !template.directLink) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: template.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
// TODO: Make this more robust, we need to ensure the owner is either
|
||||
// TODO: the member of a team that has an active subscription, is an early
|
||||
// TODO: adopter or is an enterprise user.
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
||||
match(auth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email,
|
||||
returnTo: `/embed/direct/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { directTemplateRecipientId } = template.directLink;
|
||||
|
||||
const recipient = template.recipients.find(
|
||||
(recipient) => recipient.id === directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||
|
||||
return superLoaderJson({
|
||||
token,
|
||||
user,
|
||||
template,
|
||||
recipient,
|
||||
fields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
});
|
||||
}
|
||||
|
||||
export default function EmbedDirectTemplatePage() {
|
||||
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<DocumentSigningProvider
|
||||
email={user?.email}
|
||||
fullName={user?.name}
|
||||
signature={user?.signature}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||
<EmbedDirectTemplateClientPage
|
||||
token={token}
|
||||
envelopeId={template.envelopeId}
|
||||
updatedAt={template.updatedAt}
|
||||
documentData={template.templateDocumentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhiteLabelling={allowEmbedSigningWhitelabel}
|
||||
/>
|
||||
</DocumentSigningRecipientProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
);
|
||||
}
|
||||
394
apps/remix/app/routes/embed+/_v0+/sign.$token.tsx
Normal file
394
apps/remix/app/routes/embed+/_v0+/sign.$token.tsx
Normal file
@ -0,0 +1,394 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { data } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { EmbedSignDocumentV1ClientPage } from '~/components/embed/embed-document-signing-page-v1';
|
||||
import { EmbedSignDocumentV2ClientPage } from '~/components/embed/embed-document-signing-page-v2';
|
||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import { getOptionalLoaderContext } from '../../../../server/utils/get-loader-session';
|
||||
import type { Route } from './+types/sign.$token';
|
||||
|
||||
async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
||||
const { requestMetadata } = getOptionalLoaderContext();
|
||||
|
||||
if (!params.token) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.token;
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
userId: user?.id,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
getCompletedFieldsForToken({ token }).catch(() => []),
|
||||
]);
|
||||
|
||||
// `document.directLink` is always available but we're doing this to
|
||||
// satisfy the type checker.
|
||||
if (!document || !recipient) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
// TODO: Make this more robust, we need to ensure the owner is either
|
||||
// TODO: the member of a team that has an active subscription, is an early
|
||||
// TODO: adopter or is an enterprise user.
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||
match(accesssAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email || recipient.email,
|
||||
returnTo: `/embed/sign/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
||||
|
||||
if (!isRecipientsTurnToSign) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-waiting-for-turn',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
});
|
||||
|
||||
const allRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? await getRecipientsForAssistant({
|
||||
token,
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
document,
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||
const { requestMetadata } = getOptionalLoaderContext();
|
||||
|
||||
if (!params.token) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.token;
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const envelopeForSigning = await getEnvelopeForRecipientSigning({
|
||||
token,
|
||||
userId: user?.id,
|
||||
})
|
||||
.then((envelopeForSigning) => {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
...envelopeForSigning,
|
||||
} as const;
|
||||
})
|
||||
.catch(async (e) => {
|
||||
const error = AppError.parseError(e);
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
...requiredAccessData,
|
||||
} as const;
|
||||
}
|
||||
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
});
|
||||
|
||||
if (!envelopeForSigning.isDocumentAccessValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: envelopeForSigning.recipientEmail,
|
||||
returnTo: `/embed/sign/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { envelope, recipient, isRecipientsTurn } = envelopeForSigning;
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-waiting-for-turn',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||
match(accesssAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email || recipient.email,
|
||||
returnTo: `/embed/sign/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
}).catch(() => null);
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
envelopeForSigning,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
const { token } = loaderArgs.params;
|
||||
|
||||
if (!token) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Not efficient but works for now until we remove v1.
|
||||
const foundRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
envelope: {
|
||||
select: {
|
||||
internalVersion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundRecipient) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (foundRecipient.envelope.internalVersion === 2) {
|
||||
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 2,
|
||||
payload: payloadV2,
|
||||
} as const);
|
||||
}
|
||||
|
||||
const payloadV1 = await handleV1Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 1,
|
||||
payload: payloadV1,
|
||||
} as const);
|
||||
}
|
||||
|
||||
export default function EmbedSignDocumentPage() {
|
||||
const { version, payload } = useSuperLoaderData<typeof loader>();
|
||||
|
||||
if (version === 1) {
|
||||
return <EmbedSignDocumentPageV1 data={payload} />;
|
||||
}
|
||||
|
||||
return <EmbedSignDocumentPageV2 data={payload} />;
|
||||
}
|
||||
|
||||
const EmbedSignDocumentPageV1 = ({
|
||||
data,
|
||||
}: {
|
||||
data: Awaited<ReturnType<typeof handleV1Loader>>;
|
||||
}) => {
|
||||
const {
|
||||
token,
|
||||
user,
|
||||
document,
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DocumentSigningProvider
|
||||
email={recipient.email}
|
||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={document.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EmbedSignDocumentV1ClientPage
|
||||
token={token}
|
||||
documentId={document.id}
|
||||
envelopeId={document.envelopeId}
|
||||
envelopeItems={document.envelopeItems}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
metadata={document.documentMeta}
|
||||
isCompleted={isDocumentCompleted(document.status)}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
allRecipients={allRecipients}
|
||||
/>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const EmbedSignDocumentPageV2 = ({
|
||||
data,
|
||||
}: {
|
||||
data: Awaited<ReturnType<typeof handleV2Loader>>;
|
||||
}) => {
|
||||
const { token, user, envelopeForSigning, hidePoweredBy, allowEmbedSigningWhitelabel } = data;
|
||||
|
||||
const { envelope, recipient } = envelopeForSigning;
|
||||
|
||||
return (
|
||||
<EnvelopeSigningProvider
|
||||
envelopeData={envelopeForSigning}
|
||||
email={recipient.email}
|
||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={envelope.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EnvelopeRenderProvider envelope={envelope} token={token}>
|
||||
<EmbedSignDocumentV2ClientPage
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
/>
|
||||
</EnvelopeRenderProvider>
|
||||
</DocumentSigningAuthProvider>
|
||||
</EnvelopeSigningProvider>
|
||||
);
|
||||
};
|
||||
@ -1,181 +0,0 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { data } from 'react-router';
|
||||
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
|
||||
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
|
||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||
|
||||
import type { Route } from './+types/sign.$url';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { requestMetadata } = getOptionalLoaderContext();
|
||||
|
||||
if (!params.url) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const token = params.url;
|
||||
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
userId: user?.id,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
getCompletedFieldsForToken({ token }).catch(() => []),
|
||||
]);
|
||||
|
||||
// `document.directLink` is always available but we're doing this to
|
||||
// satisfy the type checker.
|
||||
if (!document || !recipient) {
|
||||
throw new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
|
||||
|
||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
|
||||
// TODO: Make this more robust, we need to ensure the owner is either
|
||||
// TODO: the member of a team that has an active subscription, is an early
|
||||
// TODO: adopter or is an enterprise user.
|
||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-paywall',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||
match(accesssAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-authentication-required',
|
||||
email: user?.email || recipient.email,
|
||||
returnTo: `/embed/sign/${token}`,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
||||
|
||||
if (!isRecipientsTurnToSign) {
|
||||
throw data(
|
||||
{
|
||||
type: 'embed-waiting-for-turn',
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await viewedDocument({
|
||||
token,
|
||||
requestMetadata,
|
||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||
});
|
||||
|
||||
const allRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? await getRecipientsForAssistant({
|
||||
token,
|
||||
})
|
||||
: [];
|
||||
|
||||
return superLoaderJson({
|
||||
token,
|
||||
user,
|
||||
document,
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
});
|
||||
}
|
||||
|
||||
export default function EmbedSignDocumentPage() {
|
||||
const {
|
||||
token,
|
||||
user,
|
||||
document,
|
||||
allRecipients,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
} = useSuperLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<DocumentSigningProvider
|
||||
email={recipient.email}
|
||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={document.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EmbedSignDocumentClientPage
|
||||
token={token}
|
||||
documentId={document.id}
|
||||
envelopeId={document.envelopeId}
|
||||
documentData={document.documentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
metadata={document.documentMeta}
|
||||
isCompleted={isDocumentCompleted(document.status)}
|
||||
hidePoweredBy={hidePoweredBy}
|
||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||
allRecipients={allRecipients}
|
||||
/>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
);
|
||||
}
|
||||
@ -67,6 +67,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
export default function MultisignPage() {
|
||||
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const [selectedDocument, setSelectedDocument] = useState<
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TFieldCheckbox } from '@documenso/lib/types/field';
|
||||
import { parseCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||
@ -44,6 +45,13 @@ export const handleCheckboxFieldClick = async (
|
||||
|
||||
let checkedValues: number[] | null = newValues.filter((v) => v.isChecked).map((v) => v.index);
|
||||
|
||||
if (checkedValues.length === 0) {
|
||||
return {
|
||||
type: FieldType.CHECKBOX,
|
||||
value: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (validationRule && validationLength) {
|
||||
const checkboxValidationRule = checkboxValidationSigns.find(
|
||||
(sign) => sign.label === validationRule,
|
||||
@ -55,12 +63,33 @@ export const handleCheckboxFieldClick = async (
|
||||
});
|
||||
}
|
||||
|
||||
checkedValues = await SignFieldCheckboxDialog.call({
|
||||
fieldMeta: field.fieldMeta,
|
||||
validationRule: checkboxValidationRule.value,
|
||||
// Custom logic to make it flow better.
|
||||
// If "at most" OR "exactly" 1 value then just return the new selected value if exists.
|
||||
if (
|
||||
(checkboxValidationRule.value === '=' || checkboxValidationRule.value === '<=') &&
|
||||
validationLength === 1
|
||||
) {
|
||||
return {
|
||||
type: FieldType.CHECKBOX,
|
||||
value: [clickedCheckboxIndex],
|
||||
};
|
||||
}
|
||||
|
||||
const isValid = validateCheckboxLength(
|
||||
checkedValues.length,
|
||||
checkboxValidationRule.value,
|
||||
validationLength,
|
||||
preselectedIndices: currentCheckedIndices,
|
||||
});
|
||||
);
|
||||
|
||||
// Only render validation dialog if validation is invalid.
|
||||
if (!isValid) {
|
||||
checkedValues = await SignFieldCheckboxDialog.call({
|
||||
fieldMeta: field.fieldMeta,
|
||||
validationRule: checkboxValidationRule.value,
|
||||
validationLength,
|
||||
preselectedIndices: checkedValues,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkedValues) {
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@epic-web/remember": "^1.1.0",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@hono/trpc-server": "^0.3.4",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
@ -40,6 +41,7 @@
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"colord": "^2.9.3",
|
||||
"content-disposition": "^0.5.4",
|
||||
"framer-motion": "^10.12.8",
|
||||
"hono": "4.7.0",
|
||||
"hono-rate-limiter": "^0.4.2",
|
||||
@ -86,6 +88,7 @@
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@simplewebauthn/types": "^9.0.1",
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
"@types/formidable": "^2.0.6",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "^20",
|
||||
@ -103,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.13.1"
|
||||
}
|
||||
"version": "2.0.2"
|
||||
}
|
||||
|
||||
192
apps/remix/server/api/download/download.ts
Normal file
192
apps/remix/server/api/download/download.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { handleEnvelopeItemFileRequest } from '../files/files.helpers';
|
||||
import {
|
||||
ZDownloadDocumentRequestParamsSchema,
|
||||
ZDownloadEnvelopeItemRequestParamsSchema,
|
||||
} from './download.types';
|
||||
|
||||
export const downloadRoute = new Hono<HonoEnv>()
|
||||
/**
|
||||
* Download an envelope item by its ID.
|
||||
* Requires API key authentication via Authorization header.
|
||||
*/
|
||||
.get(
|
||||
'/envelopeItem/:envelopeItemId/download',
|
||||
sValidator('param', ZDownloadEnvelopeItemRequestParamsSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { envelopeItemId, version } = c.req.valid('param');
|
||||
const authorizationHeader = c.req.header('authorization');
|
||||
|
||||
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token was not provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
if (apiToken.user.disabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
auth: 'api',
|
||||
source: 'apiV2',
|
||||
path: c.req.path,
|
||||
userId: apiToken.user.id,
|
||||
apiTokenId: apiToken.id,
|
||||
envelopeItemId,
|
||||
version,
|
||||
});
|
||||
|
||||
const envelopeItem = await prisma.envelopeItem.findFirst({
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
team: buildTeamWhereQuery({ teamId: apiToken.teamId, userId: apiToken.user.id }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Envelope item not found' }, 404);
|
||||
}
|
||||
|
||||
if (!envelopeItem.documentData) {
|
||||
return c.json({ error: 'Document data not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemFileRequest({
|
||||
title: envelopeItem.title,
|
||||
status: envelopeItem.envelope.status,
|
||||
documentData: envelopeItem.documentData,
|
||||
version: version || 'signed',
|
||||
isDownload: true,
|
||||
context: c,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
|
||||
if (error instanceof AppError) {
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
return c.json({ error: error.message }, 401);
|
||||
}
|
||||
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
},
|
||||
)
|
||||
/**
|
||||
* Download a document by its ID.
|
||||
* Requires API key authentication via Authorization header.
|
||||
*/
|
||||
.get(
|
||||
'/document/:documentId/download',
|
||||
sValidator('param', ZDownloadDocumentRequestParamsSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { documentId, version } = c.req.valid('param');
|
||||
const authorizationHeader = c.req.header('authorization');
|
||||
|
||||
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token was not provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
if (apiToken.user.disabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
auth: 'api',
|
||||
source: 'apiV2',
|
||||
path: c.req.path,
|
||||
userId: apiToken.user.id,
|
||||
apiTokenId: apiToken.id,
|
||||
documentId,
|
||||
version,
|
||||
});
|
||||
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: apiToken.user.id,
|
||||
teamId: apiToken.teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Document not found' }, 404);
|
||||
}
|
||||
|
||||
// Get the first envelope item (documents have exactly one)
|
||||
const [envelopeItem] = envelope.envelopeItems;
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Document item not found' }, 404);
|
||||
}
|
||||
|
||||
if (!envelopeItem.documentData) {
|
||||
return c.json({ error: 'Document data not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemFileRequest({
|
||||
title: envelopeItem.title,
|
||||
status: envelope.status,
|
||||
documentData: envelopeItem.documentData,
|
||||
version: version || 'signed',
|
||||
isDownload: true,
|
||||
context: c,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
|
||||
if (error instanceof AppError) {
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
return c.json({ error: error.message }, 401);
|
||||
}
|
||||
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
29
apps/remix/server/api/download/download.types.ts
Normal file
29
apps/remix/server/api/download/download.types.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDownloadEnvelopeItemRequestParamsSchema = z.object({
|
||||
envelopeItemId: z.string().describe('The ID of the envelope item to download.'),
|
||||
version: z
|
||||
.enum(['original', 'signed'])
|
||||
.optional()
|
||||
.default('signed')
|
||||
.describe(
|
||||
'The version of the envelope item to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
|
||||
),
|
||||
});
|
||||
|
||||
export type TDownloadEnvelopeItemRequestParams = z.infer<
|
||||
typeof ZDownloadEnvelopeItemRequestParamsSchema
|
||||
>;
|
||||
|
||||
export const ZDownloadDocumentRequestParamsSchema = z.object({
|
||||
documentId: z.coerce.number().describe('The ID of the document to download.'),
|
||||
version: z
|
||||
.enum(['original', 'signed'])
|
||||
.optional()
|
||||
.default('signed')
|
||||
.describe(
|
||||
'The version of the document to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
|
||||
),
|
||||
});
|
||||
|
||||
export type TDownloadDocumentRequestParams = z.infer<typeof ZDownloadDocumentRequestParamsSchema>;
|
||||
@ -1,100 +0,0 @@
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { putFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import {
|
||||
getPresignGetUrl,
|
||||
getPresignPostUrl,
|
||||
} from '@documenso/lib/universal/upload/server-actions';
|
||||
|
||||
import type { HonoEnv } from '../router';
|
||||
import {
|
||||
type TGetPresignedGetUrlResponse,
|
||||
type TGetPresignedPostUrlResponse,
|
||||
ZGetPresignedGetUrlRequestSchema,
|
||||
ZGetPresignedPostUrlRequestSchema,
|
||||
ZUploadPdfRequestSchema,
|
||||
} from './files.types';
|
||||
|
||||
export const filesRoute = new Hono<HonoEnv>()
|
||||
/**
|
||||
* Uploads a document file to the appropriate storage location and creates
|
||||
* a document data record.
|
||||
*/
|
||||
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
|
||||
try {
|
||||
const { file } = c.req.valid('form');
|
||||
|
||||
if (!file) {
|
||||
return c.json({ error: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// Todo: (RR7) This is new.
|
||||
// Add file size validation.
|
||||
// Convert MB to bytes (1 MB = 1024 * 1024 bytes)
|
||||
const MAX_FILE_SIZE = APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024;
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return c.json({ error: 'File too large' }, 400);
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
|
||||
console.error(`PDF upload parse error: ${e.message}`);
|
||||
|
||||
throw new AppError('INVALID_DOCUMENT_FILE');
|
||||
});
|
||||
|
||||
if (pdf.isEncrypted) {
|
||||
throw new AppError('INVALID_DOCUMENT_FILE');
|
||||
}
|
||||
|
||||
// Todo: (RR7) Test this.
|
||||
if (!file.name.endsWith('.pdf')) {
|
||||
Object.defineProperty(file, 'name', {
|
||||
writable: true,
|
||||
value: `${file.name}.pdf`,
|
||||
});
|
||||
}
|
||||
|
||||
const { type, data } = await putFileServerSide(file);
|
||||
|
||||
const result = await createDocumentData({ type, data });
|
||||
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
})
|
||||
.post('/presigned-get-url', sValidator('json', ZGetPresignedGetUrlRequestSchema), async (c) => {
|
||||
const { key } = await c.req.json();
|
||||
|
||||
try {
|
||||
const { url } = await getPresignGetUrl(key || '');
|
||||
|
||||
return c.json({ url } satisfies TGetPresignedGetUrlResponse);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
|
||||
}
|
||||
})
|
||||
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
|
||||
const { fileName, contentType } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
const { key, url } = await getPresignPostUrl(fileName, contentType);
|
||||
|
||||
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
|
||||
}
|
||||
});
|
||||
@ -1,38 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
|
||||
export const ZUploadPdfRequestSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
});
|
||||
|
||||
export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
|
||||
type: true,
|
||||
id: true,
|
||||
});
|
||||
|
||||
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
||||
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
||||
|
||||
export const ZGetPresignedPostUrlRequestSchema = z.object({
|
||||
fileName: z.string().min(1),
|
||||
contentType: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZGetPresignedPostUrlResponseSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZGetPresignedGetUrlRequestSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZGetPresignedGetUrlResponseSchema = z.object({
|
||||
url: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
|
||||
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
|
||||
export type TGetPresignedGetUrlRequest = z.infer<typeof ZGetPresignedGetUrlRequestSchema>;
|
||||
export type TGetPresignedGetUrlResponse = z.infer<typeof ZGetPresignedGetUrlResponseSchema>;
|
||||
81
apps/remix/server/api/files/files.helpers.ts
Normal file
81
apps/remix/server/api/files/files.helpers.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { type Context } from 'hono';
|
||||
|
||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
|
||||
type HandleEnvelopeItemFileRequestOptions = {
|
||||
title: string;
|
||||
status: DocumentStatus;
|
||||
documentData: {
|
||||
type: DocumentDataType;
|
||||
data: string;
|
||||
initialData: string;
|
||||
};
|
||||
version: 'signed' | 'original';
|
||||
isDownload: boolean;
|
||||
context: Context<HonoEnv>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to handle envelope item file requests (both view and download)
|
||||
*/
|
||||
export const handleEnvelopeItemFileRequest = async ({
|
||||
title,
|
||||
status,
|
||||
documentData,
|
||||
version,
|
||||
isDownload,
|
||||
context: c,
|
||||
}: HandleEnvelopeItemFileRequestOptions) => {
|
||||
const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData;
|
||||
|
||||
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
|
||||
|
||||
if (c.req.header('If-None-Match') === etag && !isDownload) {
|
||||
return c.body(null, 304);
|
||||
}
|
||||
|
||||
const file = await getFileServerSide({
|
||||
type: documentData.type,
|
||||
data: documentDataToUse,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
c.header('ETag', etag);
|
||||
|
||||
if (!isDownload) {
|
||||
if (status === DocumentStatus.COMPLETED) {
|
||||
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
} else {
|
||||
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
}
|
||||
}
|
||||
|
||||
if (isDownload) {
|
||||
// Generate filename following the pattern from envelope-download-dialog.tsx
|
||||
const baseTitle = title.replace(/\.pdf$/, '');
|
||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||
const filename = `${baseTitle}${suffix}`;
|
||||
|
||||
c.header('Content-Disposition', contentDisposition(filename));
|
||||
|
||||
// For downloads, prevent caching to ensure fresh data
|
||||
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
c.header('Pragma', 'no-cache');
|
||||
c.header('Expires', '0');
|
||||
}
|
||||
|
||||
return c.body(file);
|
||||
};
|
||||
307
apps/remix/server/api/files/files.ts
Normal file
307
apps/remix/server/api/files/files.ts
Normal file
@ -0,0 +1,307 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { handleEnvelopeItemFileRequest } from './files.helpers';
|
||||
import {
|
||||
type TGetPresignedPostUrlResponse,
|
||||
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
|
||||
ZGetEnvelopeItemFileTokenRequestParamsSchema,
|
||||
ZGetPresignedPostUrlRequestSchema,
|
||||
ZUploadPdfRequestSchema,
|
||||
} from './files.types';
|
||||
|
||||
export const filesRoute = new Hono<HonoEnv>()
|
||||
/**
|
||||
* Uploads a document file to the appropriate storage location and creates
|
||||
* a document data record.
|
||||
*/
|
||||
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
|
||||
try {
|
||||
const { file } = c.req.valid('form');
|
||||
|
||||
if (!file) {
|
||||
return c.json({ error: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// Todo: (RR7) This is new.
|
||||
// Add file size validation.
|
||||
// Convert MB to bytes (1 MB = 1024 * 1024 bytes)
|
||||
const MAX_FILE_SIZE = APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024;
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return c.json({ error: 'File too large' }, 400);
|
||||
}
|
||||
|
||||
const result = await putNormalizedPdfFileServerSide(file);
|
||||
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
})
|
||||
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
|
||||
const { fileName, contentType } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
const { key, url } = await getPresignPostUrl(fileName, contentType);
|
||||
|
||||
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
|
||||
}
|
||||
})
|
||||
.get(
|
||||
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
|
||||
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
|
||||
async (c) => {
|
||||
const { envelopeId, envelopeItemId } = c.req.valid('param');
|
||||
|
||||
const session = await getOptionalSession(c);
|
||||
|
||||
if (!session.user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Envelope not found' }, 404);
|
||||
}
|
||||
|
||||
const [envelopeItem] = envelope.envelopeItems;
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Envelope item not found' }, 404);
|
||||
}
|
||||
|
||||
const team = await getTeamById({
|
||||
userId: session.user.id,
|
||||
teamId: envelope.teamId,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return c.json(
|
||||
{ error: 'User does not have access to the team that this envelope is associated with' },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if (!envelopeItem.documentData) {
|
||||
return c.json({ error: 'Document data not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemFileRequest({
|
||||
title: envelopeItem.title,
|
||||
status: envelope.status,
|
||||
documentData: envelopeItem.documentData,
|
||||
version: 'signed',
|
||||
isDownload: false,
|
||||
context: c,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/download/:version?',
|
||||
sValidator('param', ZGetEnvelopeItemFileDownloadRequestParamsSchema),
|
||||
async (c) => {
|
||||
const { envelopeId, envelopeItemId, version } = c.req.valid('param');
|
||||
|
||||
const session = await getOptionalSession(c);
|
||||
|
||||
if (!session.user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Envelope not found' }, 404);
|
||||
}
|
||||
|
||||
const [envelopeItem] = envelope.envelopeItems;
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Envelope item not found' }, 404);
|
||||
}
|
||||
|
||||
const team = await getTeamById({
|
||||
userId: session.user.id,
|
||||
teamId: envelope.teamId,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return c.json(
|
||||
{ error: 'User does not have access to the team that this envelope is associated with' },
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
if (!envelopeItem.documentData) {
|
||||
return c.json({ error: 'Document data not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemFileRequest({
|
||||
title: envelopeItem.title,
|
||||
status: envelope.status,
|
||||
documentData: envelopeItem.documentData,
|
||||
version,
|
||||
isDownload: true,
|
||||
context: c,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'/token/:token/envelopeItem/:envelopeItemId',
|
||||
sValidator('param', ZGetEnvelopeItemFileTokenRequestParamsSchema),
|
||||
async (c) => {
|
||||
const { token, envelopeItemId } = c.req.valid('param');
|
||||
|
||||
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (token.startsWith('qr_')) {
|
||||
envelopeWhereQuery = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
qrToken: token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const envelopeItem = await prisma.envelopeItem.findUnique({
|
||||
where: envelopeWhereQuery,
|
||||
include: {
|
||||
envelope: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Envelope item not found' }, 404);
|
||||
}
|
||||
|
||||
if (!envelopeItem.documentData) {
|
||||
return c.json({ error: 'Document data not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemFileRequest({
|
||||
title: envelopeItem.title,
|
||||
status: envelopeItem.envelope.status,
|
||||
documentData: envelopeItem.documentData,
|
||||
version: 'signed',
|
||||
isDownload: false,
|
||||
context: c,
|
||||
});
|
||||
},
|
||||
)
|
||||
.get(
|
||||
'/token/:token/envelopeItem/:envelopeItemId/download/:version?',
|
||||
sValidator('param', ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema),
|
||||
async (c) => {
|
||||
const { token, envelopeItemId, version } = c.req.valid('param');
|
||||
|
||||
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (token.startsWith('qr_')) {
|
||||
envelopeWhereQuery = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
qrToken: token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const envelopeItem = await prisma.envelopeItem.findUnique({
|
||||
where: envelopeWhereQuery,
|
||||
include: {
|
||||
envelope: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Envelope item not found' }, 404);
|
||||
}
|
||||
|
||||
if (!envelopeItem.documentData) {
|
||||
return c.json({ error: 'Document data not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemFileRequest({
|
||||
title: envelopeItem.title,
|
||||
status: envelopeItem.envelope.status,
|
||||
documentData: envelopeItem.documentData,
|
||||
version,
|
||||
isDownload: true,
|
||||
context: c,
|
||||
});
|
||||
},
|
||||
);
|
||||
66
apps/remix/server/api/files/files.types.ts
Normal file
66
apps/remix/server/api/files/files.types.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
|
||||
export const ZUploadPdfRequestSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
});
|
||||
|
||||
export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
|
||||
type: true,
|
||||
id: true,
|
||||
});
|
||||
|
||||
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
||||
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
||||
|
||||
export const ZGetPresignedPostUrlRequestSchema = z.object({
|
||||
fileName: z.string().min(1),
|
||||
contentType: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZGetPresignedPostUrlResponseSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
|
||||
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
|
||||
|
||||
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
|
||||
envelopeId: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TGetEnvelopeItemFileRequestParams = z.infer<
|
||||
typeof ZGetEnvelopeItemFileRequestParamsSchema
|
||||
>;
|
||||
|
||||
export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TGetEnvelopeItemFileTokenRequestParams = z.infer<
|
||||
typeof ZGetEnvelopeItemFileTokenRequestParamsSchema
|
||||
>;
|
||||
|
||||
export const ZGetEnvelopeItemFileDownloadRequestParamsSchema = z.object({
|
||||
envelopeId: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
version: z.enum(['signed', 'original']).default('signed'),
|
||||
});
|
||||
|
||||
export type TGetEnvelopeItemFileDownloadRequestParams = z.infer<
|
||||
typeof ZGetEnvelopeItemFileDownloadRequestParamsSchema
|
||||
>;
|
||||
|
||||
export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
version: z.enum(['signed', 'original']).default('signed'),
|
||||
});
|
||||
|
||||
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
|
||||
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema
|
||||
>;
|
||||
@ -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 });
|
||||
|
||||
@ -8,13 +8,14 @@ import type { Logger } from 'pino';
|
||||
|
||||
import { tsRestHonoApp } from '@documenso/api/hono';
|
||||
import { auth } from '@documenso/auth/server';
|
||||
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
|
||||
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||
|
||||
import { filesRoute } from './api/files';
|
||||
import { downloadRoute } from './api/download/download';
|
||||
import { filesRoute } from './api/files/files';
|
||||
import { type AppContext, appContext } from './context';
|
||||
import { appMiddleware } from './middleware';
|
||||
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
||||
@ -89,9 +90,26 @@ app.route('/api/v1', tsRestHonoApp);
|
||||
app.use('/api/jobs/*', jobsClient.getApiHandler());
|
||||
app.use('/api/trpc/*', reactRouterTrpcServer);
|
||||
|
||||
// Unstable API server routes. Order matters for these two.
|
||||
app.get(`${API_V2_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||
app.use(`${API_V2_URL}/*`, cors());
|
||||
// Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types.
|
||||
app.route(`${API_V2_URL}`, downloadRoute);
|
||||
app.use(`${API_V2_URL}/*`, async (c) =>
|
||||
openApiTrpcServerHandler(c, {
|
||||
isBeta: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Unstable API server routes. Order matters for these two.
|
||||
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||
app.use(`${API_V2_BETA_URL}/*`, cors());
|
||||
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
|
||||
// Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types.
|
||||
app.route(`${API_V2_BETA_URL}`, downloadRoute);
|
||||
app.use(`${API_V2_BETA_URL}/*`, async (c) =>
|
||||
openApiTrpcServerHandler(c, {
|
||||
isBeta: true,
|
||||
}),
|
||||
);
|
||||
|
||||
export default app;
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
|
||||
|
||||
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
|
||||
import { API_V2_BETA_URL, API_V2_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) => {
|
||||
type OpenApiTrpcServerHandlerOptions = {
|
||||
isBeta: boolean;
|
||||
};
|
||||
|
||||
export const openApiTrpcServerHandler = async (
|
||||
c: Context,
|
||||
{ isBeta }: OpenApiTrpcServerHandlerOptions,
|
||||
) => {
|
||||
return createOpenApiFetchHandler<typeof appRouter>({
|
||||
endpoint: API_V2_BETA_URL,
|
||||
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL,
|
||||
router: appRouter,
|
||||
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
|
||||
req: c.req.raw,
|
||||
|
||||
@ -21,7 +21,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
|
||||
BIN
assets/field-font-alignment.pdf
Normal file
BIN
assets/field-font-alignment.pdf
Normal file
Binary file not shown.
BIN
assets/field-meta.pdf
Normal file
BIN
assets/field-meta.pdf
Normal file
Binary file not shown.
797
package-lock.json
generated
797
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.13.1",
|
||||
"version": "2.0.2",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
@ -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,22 @@
|
||||
"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-json-types-generator": "^3.6.2",
|
||||
"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,10 +87,10 @@
|
||||
"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"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user