mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
26 Commits
fix/field-
...
0977c16e33
| Author | SHA1 | Date | |
|---|---|---|---|
| 0977c16e33 | |||
| 88d5a636c3 | |||
| 1e6292b1d9 | |||
| d65866156d | |||
| fe8915162f | |||
| 37a2634aca | |||
| 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}
|
||||
/>
|
||||
|
||||
@ -7,6 +7,7 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
|
||||
export type DocumentPageViewInformationProps = {
|
||||
userId: number;
|
||||
@ -40,6 +41,10 @@ export const DocumentPageViewInformation = ({
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toRelative(),
|
||||
},
|
||||
{
|
||||
description: msg`Document ID (Legacy)`,
|
||||
value: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMounted, envelope, userId]);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -7,11 +7,13 @@ import type { User } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
|
||||
export type TemplatePageViewInformationProps = {
|
||||
userId: number;
|
||||
template: {
|
||||
userId: number;
|
||||
secondaryId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
@ -43,6 +45,10 @@ export const TemplatePageViewInformation = ({
|
||||
.setLocale(i18n.locales?.[0] || i18n.locale)
|
||||
.toRelative(),
|
||||
},
|
||||
{
|
||||
description: msg`Template ID (Legacy)`,
|
||||
value: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMounted, template, userId]);
|
||||
|
||||
@ -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.5"
|
||||
}
|
||||
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user