Compare commits

..

1 Commits

Author SHA1 Message Date
a66a56042c feat: add envelopes api 2025-10-28 12:13:26 +11:00
322 changed files with 7312 additions and 20230 deletions

View File

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

View File

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

View File

@ -19,15 +19,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = {
id: string;
token?: string;
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DocumentDuplicateDialog = ({
id,
token,
open,
onOpenChange,
}: DocumentDuplicateDialogProps) => {
@ -38,23 +36,27 @@ export const DocumentDuplicateDialog = ({
const team = useCurrentTeam();
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpcReact.envelope.item.getManyByToken.useQuery(
const { data: document, isLoading } = trpcReact.document.get.useQuery(
{
envelopeId: id,
access: token ? { type: 'recipient', token } : { type: 'user' },
documentId: id,
},
{
enabled: open,
queryHash: `document-duplicate-dialog-${id}`,
enabled: open === true,
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const documentData = document?.documentData
? {
...document.documentData,
data: document.documentData.initialData,
}
: undefined;
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpcReact.envelope.duplicate.useMutation({
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: _(msg`Document Duplicated`),
@ -69,7 +71,7 @@ export const DocumentDuplicateDialog = ({
const onDuplicate = async () => {
try {
await duplicateEnvelope({ envelopeId: id });
await duplicateDocument({ documentId: id });
} catch {
toast({
title: _(msg`Something went wrong`),
@ -81,14 +83,14 @@ export const DocumentDuplicateDialog = ({
};
return (
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Duplicate</Trans>
</DialogTitle>
</DialogHeader>
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
{!documentData || isLoading ? (
<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>
@ -96,12 +98,7 @@ export const DocumentDuplicateDialog = ({
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewer
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={undefined}
version="original"
/>
<PDFViewer key={document?.id} documentData={documentData} />
</div>
)}
@ -118,8 +115,8 @@ export const DocumentDuplicateDialog = ({
<Button
type="button"
disabled={isDuplicating}
loading={isDuplicating}
disabled={isDuplicateLoading || isLoading}
loading={isDuplicateLoading}
onClick={onDuplicate}
className="flex-1"
>

View File

@ -336,7 +336,7 @@ export const EnvelopeDistributeDialog = ({
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4">

View File

@ -2,10 +2,11 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { DownloadIcon, FileTextIcon } from 'lucide-react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -19,7 +20,9 @@ import {
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'title' | 'order'> & {
documentData: DocumentData;
};
type EnvelopeDownloadDialogProps = {
envelopeId: string;
@ -61,12 +64,12 @@ export const EnvelopeDownloadDialog = ({
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
initialData: initialEnvelopeItems ? { envelopeItems: initialEnvelopeItems } : undefined,
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
const onDownload = async (
envelopeItem: EnvelopeItemToDownload,
@ -84,11 +87,25 @@ export const EnvelopeDownloadDialog = ({
}));
try {
await downloadPDF({
envelopeItem,
token,
fileName: envelopeItem.title,
version,
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,
});
setIsDownloadingState((prev) => ({
@ -129,7 +146,7 @@ export const EnvelopeDownloadDialog = ({
<div className="flex flex-col gap-4">
{isLoadingEnvelopeItems ? (
<>
{Array.from({ length: 1 }).map((_, index) => (
{Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
@ -158,7 +175,6 @@ 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>
@ -176,7 +192,7 @@ export const EnvelopeDownloadDialog = ({
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans context="Original document (adjective)">Original</Trans>
<Trans>Original</Trans>
</Button>
{envelopeStatus === DocumentStatus.COMPLETED && (
@ -190,7 +206,7 @@ export const EnvelopeDownloadDialog = ({
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans context="Signed document (adjective)">Signed</Trans>
<Trans>Signed</Trans>
</Button>
)}
</div>

View File

@ -43,7 +43,7 @@ export const EnvelopeDuplicateDialog = ({
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
onSuccess: async ({ duplicatedEnvelopeId }) => {
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}/${id}/edit`);
await navigate(`${path}/${duplicatedEnvelopeId}/edit`);
setOpen(false);
},
});

View File

@ -185,10 +185,6 @@ 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';

View File

@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
@ -209,11 +209,7 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
))
.with('TOO_MANY_PASSKEYS', () => (
<AlertDescription>
<Plural
value={MAXIMUM_PASSKEYS}
one="You cannot have more than # passkey."
other="You cannot have more than # passkeys."
/>
<Trans>You cannot have more than {MAXIMUM_PASSKEYS} passkeys.</Trans>
</AlertDescription>
))
.with('InvalidStateError', () => (

View File

@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
@ -27,70 +28,48 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
export type SignFieldNumberDialogProps = {
fieldMeta: TNumberFieldMeta;
};
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
let schema = z.coerce.number({
invalid_type_error: msg`Please enter a valid number`.id,
});
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, string | null>(
({ call, fieldMeta }) => {
const { t } = useLingui();
// Needs to be inside dialog for translation purposes.
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
const { numberFormat, minValue, maxValue } = fieldMeta;
if (typeof minValue === 'number') {
schema = schema.min(minValue);
}
if (typeof maxValue === 'number') {
schema = schema.max(maxValue);
}
if (numberFormat) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (foundRegex) {
return z.string().refine(
if (!foundRegex) {
return schema;
}
return schema.refine(
(value) => {
return foundRegex.test(value.toString());
},
{
message: t`Number needs to be formatted as ${numberFormat}`,
message: msg`Number needs to be formatted as ${numberFormat}`.id,
},
);
}
}
// Not gong to work with min/max numbers + number format
// Since currently doesn't work in V1 going to ignore for now.
return z.string().superRefine((value, ctx) => {
const isValidNumber = /^[0-9,.]+$/.test(value.toString());
return schema;
};
if (!isValidNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t`Please enter a valid number`,
});
export type SignFieldNumberDialogProps = {
fieldMeta: TNumberFieldMeta;
};
return;
}
if (typeof minValue === 'number' && parseFloat(value) < minValue) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: minValue,
inclusive: true,
type: 'number',
});
return;
}
if (typeof maxValue === 'number' && parseFloat(value) > maxValue) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: maxValue,
inclusive: true,
type: 'number',
});
return;
}
});
};
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | null>(
({ call, fieldMeta }) => {
const { t } = useLingui();
const ZSignFieldNumberFormSchema = z.object({
number: createNumberFieldSchema(fieldMeta),

View File

@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -54,17 +54,13 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true);
try {
const payload = {
const response = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
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`),
@ -96,7 +92,7 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
<Trans>Template (Legacy)</Trans>
<Trans>New Template</Trans>
</Button>
</DialogTrigger>

View File

@ -265,7 +265,7 @@ export const TemplateDirectLinkDialog = ({
{remaining.directTemplates !== 0 && (
<DialogFooter className="mx-auto mt-4">
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
<Trans>Enable direct link signing</Trans>
<Trans> Enable direct link signing</Trans>
</Button>
</DialogFooter>
)}

View File

@ -143,7 +143,7 @@ export function TemplateUseDialog({
},
);
const envelopeItems = response?.data ?? [];
const envelopeItems = response?.envelopeItems ?? [];
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();

View File

@ -4,7 +4,6 @@ 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';
@ -13,6 +12,7 @@ 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,14 +83,21 @@ export const ConfigureFieldsView = ({
const normalizedDocumentData = useMemo(() => {
if (documentData) {
return documentData.data;
return documentData;
}
if (!configData.documentData) {
return null;
}
return base64.encode(configData.documentData.data);
const data = base64.encode(configData.documentData?.data);
return {
id: 'preview',
type: 'BYTES_64',
data,
initialData: data,
} satisfies DocumentData;
}, [configData.documentData]);
const recipients = useMemo(() => {
@ -534,15 +541,7 @@ export const ConfigureFieldsView = ({
<Form {...form}>
{normalizedDocumentData && (
<div>
<PDFViewer
overrideData={normalizedDocumentData}
envelopeItem={{
id: '',
envelopeId: '',
}}
token={undefined}
version="signed"
/>
<PDFViewer documentData={normalizedDocumentData} />
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}

View File

@ -9,7 +9,6 @@ export type EmbedAuthenticationRequiredProps = {
email?: string;
returnTo: string;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
};
@ -18,7 +17,6 @@ export const EmbedAuthenticationRequired = ({
email,
returnTo,
// isGoogleSSOEnabled,
// isMicrosoftSSOEnabled,
// isOIDCSSOEnabled,
// oidcProviderLabel,
}: EmbedAuthenticationRequiredProps) => {
@ -39,7 +37,6 @@ export const EmbedAuthenticationRequired = ({
<SignInForm
// Embed currently not supported.
// isGoogleSSOEnabled={isGoogleSSOEnabled}
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
// isOIDCSSOEnabled={isOIDCSSOEnabled}
// oidcProviderLabel={oidcProviderLabel}
className="mt-4"

View File

@ -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, EnvelopeItem, Recipient, Signature } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
import { type DocumentData, 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;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
documentData: DocumentData;
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | null;
@ -59,7 +59,7 @@ export const EmbedDirectTemplateClientPage = ({
token,
envelopeId,
updatedAt,
envelopeItems,
documentData,
recipient,
fields,
metadata,
@ -335,9 +335,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
<div className="flex-1">
<PDFViewer
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="signed"
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>

View File

@ -1,232 +0,0 @@
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>
);
};

View File

@ -3,8 +3,14 @@ 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, EnvelopeItem } from '@prisma/client';
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import type { DocumentMeta } from '@prisma/client';
import {
type DocumentData,
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';
@ -40,11 +46,11 @@ import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields';
import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentV1ClientPageProps = {
export type EmbedSignDocumentClientPageProps = {
token: string;
documentId: number;
envelopeId: string;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
documentData: DocumentData;
recipient: RecipientWithFields;
fields: Field[];
completedFields: DocumentField[];
@ -55,11 +61,11 @@ export type EmbedSignDocumentV1ClientPageProps = {
allRecipients?: RecipientWithFields[];
};
export const EmbedSignDocumentV1ClientPage = ({
export const EmbedSignDocumentClientPage = ({
token,
documentId,
envelopeId,
envelopeItems,
documentData,
recipient,
fields,
completedFields,
@ -68,7 +74,7 @@ export const EmbedSignDocumentV1ClientPage = ({
hidePoweredBy = false,
allowWhitelabelling = false,
allRecipients = [],
}: EmbedSignDocumentV1ClientPageProps) => {
}: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -287,9 +293,7 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<PDFViewer
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
documentData={documentData}
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>

View File

@ -1,101 +0,0 @@
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>
);
};

View File

@ -226,9 +226,7 @@ export const MultiSignDocumentSigningView = ({
})}
>
<PDFViewer
envelopeItem={document.envelopeItems[0]}
token={token}
version="signed"
documentData={document.documentData}
onDocumentLoad={() => {
setHasDocumentLoaded(true);
onDocumentReady?.();

View File

@ -1,49 +0,0 @@
import { useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { DateRange } from '@documenso/lib/types/search-params';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DateRangeFilterProps = {
currentRange: DateRange;
};
export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
const { _ } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const handleRangeChange = (value: string) => {
startTransition(() => {
updateSearchParams({
dateRange: value as DateRange,
page: 1,
});
});
};
return (
<div className="flex items-center gap-2">
<Select value={currentRange} onValueChange={handleRangeChange} disabled={isPending}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="last30days">{_(msg`Last 30 Days`)}</SelectItem>
<SelectItem value="last90days">{_(msg`Last 90 Days`)}</SelectItem>
<SelectItem value="lastYear">{_(msg`Last Year`)}</SelectItem>
<SelectItem value="allTime">{_(msg`All Time`)}</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@ -1,14 +1,14 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } 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 { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -29,8 +29,6 @@ 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'];
@ -70,9 +68,6 @@ export function BrandingPreferencesForm({
}: BrandingPreferencesFormProps) {
const { t } = useLingui();
const team = useOptionalCurrentTeam();
const organisation = useCurrentOrganisation();
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
@ -93,13 +88,14 @@ export function BrandingPreferencesForm({
const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) {
const logoUrl =
context === 'Team'
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
void getFile(file).then((binaryData) => {
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
setPreviewUrl(logoUrl);
setPreviewUrl(objectUrl);
setHasLoadedPreview(true);
});
return;
}
}

View File

@ -7,7 +7,6 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TDateFieldMeta as DateFieldMeta,
FIELD_DEFAULT_GENERIC_ALIGN,
ZDateFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
@ -40,7 +39,7 @@ export const EditorFieldDateForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
textAlign: value.textAlign || 'left',
},
});

View File

@ -7,7 +7,6 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TEmailFieldMeta as EmailFieldMeta,
FIELD_DEFAULT_GENERIC_ALIGN,
ZEmailFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
@ -40,7 +39,7 @@ export const EditorFieldEmailForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
textAlign: value.textAlign || 'left',
},
});

View File

@ -3,10 +3,6 @@ import { useEffect } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { type Control, useFormContext } from 'react-hook-form';
import { FIELD_MIN_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
import { FIELD_MAX_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
import { FIELD_MIN_LETTER_SPACING } from '@documenso/lib/types/field-meta';
import { FIELD_MAX_LETTER_SPACING } from '@documenso/lib/types/field-meta';
import { cn } from '@documenso/ui/lib/utils';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@ -111,119 +107,6 @@ export const EditorGenericTextAlignField = ({
);
};
export const EditorGenericVerticalAlignField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="verticalAlign"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Vertical Align</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t`Select vertical align`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="top">
<Trans>Top</Trans>
</SelectItem>
<SelectItem value="middle">
<Trans>Middle</Trans>
</SelectItem>
<SelectItem value="bottom">
<Trans>Bottom</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericLineHeightField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="lineHeight"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Line Height</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={FIELD_MIN_LINE_HEIGHT}
max={FIELD_MAX_LINE_HEIGHT}
className="bg-background"
placeholder={t`Line height`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericLetterSpacingField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="letterSpacing"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Letter Spacing</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={FIELD_MIN_LETTER_SPACING}
max={FIELD_MAX_LETTER_SPACING}
className="bg-background"
placeholder={t`Letter spacing`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericRequiredField = ({
formControl,
className,

View File

@ -6,7 +6,6 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
type TInitialsFieldMeta as InitialsFieldMeta,
ZInitialsFieldMeta,
} from '@documenso/lib/types/field-meta';
@ -40,7 +39,7 @@ export const EditorFieldInitialsForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
textAlign: value.textAlign || 'left',
},
});

View File

@ -6,7 +6,6 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
type TNameFieldMeta as NameFieldMeta,
ZNameFieldMeta,
} from '@documenso/lib/types/field-meta';
@ -40,7 +39,7 @@ export const EditorFieldNameForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
textAlign: value.textAlign || 'left',
},
});

View File

@ -6,11 +6,6 @@ import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
type TNumberFieldMeta as NumberFieldMeta,
ZNumberFieldMeta,
} from '@documenso/lib/types/field-meta';
@ -36,12 +31,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
import {
EditorGenericFontSizeField,
EditorGenericLabelField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericReadOnlyField,
EditorGenericRequiredField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
@ -51,9 +43,6 @@ const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
numberFormat: true,
fontSize: true,
textAlign: true,
lineHeight: true,
letterSpacing: true,
verticalAlign: true,
required: true,
readOnly: true,
minValue: true,
@ -110,11 +99,8 @@ export const EditorFieldNumberForm = ({
placeholder: value.placeholder || '',
value: value.value || '',
numberFormat: value.numberFormat || null,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
fontSize: value.fontSize || 14,
textAlign: value.textAlign || 'left',
required: value.required || false,
readOnly: value.readOnly || false,
minValue: value.minValue,
@ -132,10 +118,6 @@ export const EditorFieldNumberForm = ({
useEffect(() => {
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.value) {
void form.trigger('value');
}
if (validatedFormValues.success) {
onValueChange({
type: 'number',
@ -148,12 +130,10 @@ export const EditorFieldNumberForm = ({
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<EditorGenericLabelField formControl={form.control} />
@ -224,12 +204,6 @@ export const EditorFieldNumberForm = ({
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>

View File

@ -5,8 +5,11 @@ import { Trans } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf';
import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta';
import {
DEFAULT_FIELD_FONT_SIZE,
type TSignatureFieldMeta,
ZSignatureFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
@ -32,7 +35,7 @@ export const EditorFieldSignatureForm = ({
resolver: zodResolver(ZSignatureFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
},
});

View File

@ -3,16 +3,11 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
type TTextFieldMeta as TextFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import {
Form,
@ -27,27 +22,23 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import {
EditorGenericFontSizeField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericReadOnlyField,
EditorGenericRequiredField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
const ZTextFieldFormSchema = ZTextFieldMeta.pick({
label: true,
placeholder: true,
text: true,
characterLimit: true,
fontSize: true,
textAlign: true,
lineHeight: true,
letterSpacing: true,
verticalAlign: true,
required: true,
readOnly: true,
}).refine(
const ZTextFieldFormSchema = z
.object({
label: z.string().optional(),
placeholder: z.string().optional(),
text: z.string().optional(),
characterLimit: z.coerce.number().min(0).optional(),
fontSize: z.coerce.number().min(8).max(96).optional(),
textAlign: z.enum(['left', 'center', 'right']).optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => {
// A read-only field must have text
return !data.readOnly || (data.text && data.text.length > 0);
@ -56,7 +47,7 @@ const ZTextFieldFormSchema = ZTextFieldMeta.pick({
message: 'A read-only field must have text',
path: ['text'],
},
);
);
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
@ -82,10 +73,7 @@ export const EditorFieldTextForm = ({
text: value.text || '',
characterLimit: value.characterLimit || 0,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
textAlign: value.textAlign || 'left',
required: value.required || false,
readOnly: value.readOnly || false,
},
@ -101,10 +89,6 @@ export const EditorFieldTextForm = ({
useEffect(() => {
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.text) {
void form.trigger('text');
}
if (validatedFormValues.success) {
onValueChange({
type: 'text',
@ -117,12 +101,10 @@ export const EditorFieldTextForm = ({
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<FormField
@ -170,18 +152,6 @@ 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>
@ -200,22 +170,11 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
className="bg-background"
placeholder={t`Character limit`}
placeholder={t`Field character limit`}
{...field}
value={field.value || ''}
onChange={(e) => {
const values = form.getValues();
const characterLimit = parseInt(e.target.value, 10) || 0;
field.onChange(characterLimit || '');
const textValue = values.text || '';
if (characterLimit > 0 && textValue.length > characterLimit) {
form.setValue('text', textValue.slice(0, characterLimit));
}
}}
/>
</FormControl>
<FormMessage />
@ -223,12 +182,6 @@ export const EditorFieldTextForm = ({
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>

View File

@ -92,7 +92,6 @@ export const SignInForm = ({
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
@ -318,8 +317,6 @@ export const SignInForm = ({
if (email) {
form.setValue('email', email);
}
setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, [form]);
return (
@ -386,8 +383,6 @@ export const SignInForm = ({
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
{!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" />
@ -421,11 +416,7 @@ export const SignInForm = ({
disabled={isSubmitting}
onClick={onSignInWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
Microsoft
</Button>
)}
@ -443,8 +434,6 @@ export const SignInForm = ({
{oidcProviderLabel || 'OIDC'}
</Button>
)}
</>
)}
<Button
type="button"

View File

@ -68,7 +68,6 @@ export type SignUpFormProps = {
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
returnTo?: string;
};
export const SignUpForm = ({
@ -77,7 +76,6 @@ export const SignUpForm = ({
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
returnTo,
}: SignUpFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -112,7 +110,7 @@ export const SignUpForm = ({
signature,
});
await navigate(returnTo ? returnTo : '/unverified-account');
await navigate(`/unverified-account`);
toast({
title: _(msg`Registration Successful`),

View File

@ -9,7 +9,6 @@ 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 {
@ -64,12 +63,10 @@ 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: debouncedSearch,
query: search,
},
{
placeholderData: (previousData) => previousData,
@ -235,7 +232,6 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
<Trans>No results found.</Trans>
</CommandEmpty>
)}
{!currentPage && (
<>
{documentPageLinks.length > 0 && (
@ -243,17 +239,14 @@ 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
@ -262,7 +255,6 @@ 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} />

View File

@ -89,10 +89,7 @@ export const DirectTemplatePageView = ({
setStep('sign');
};
const onSignDirectTemplateSubmit = async (
fields: DirectTemplateLocalField[],
nextSigner?: { name: string; email: string },
) => {
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
@ -101,7 +98,6 @@ export const DirectTemplatePageView = ({
}
const { token } = await createDocumentFromDirectTemplate({
nextSigner,
directTemplateToken,
directTemplateExternalId,
directRecipientName: fullName,
@ -153,9 +149,7 @@ export const DirectTemplatePageView = ({
<CardContent className="p-2">
<PDFViewer
key={template.id}
envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token}
version="signed"
documentData={template.templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>

View File

@ -55,13 +55,10 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
export type DirectTemplateSigningFormProps = {
flowStep: DocumentFlowStep;
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>;
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
directRecipientFields: Field[];
template: Omit<TTemplate, 'user'>;
onSubmit: (
_data: DirectTemplateLocalField[],
_nextSigner?: { name: string; email: string },
) => Promise<void>;
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
};
export type DirectTemplateLocalField = Field & {
@ -152,7 +149,7 @@ export const DirectTemplateSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const handleSubmit = async (nextSigner?: { name: string; email: string }) => {
const handleSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
@ -164,7 +161,7 @@ export const DirectTemplateSigningForm = ({
setIsSubmitting(true);
try {
await onSubmit(localFields, nextSigner);
await onSubmit(localFields);
} catch {
setIsSubmitting(false);
}
@ -221,30 +218,6 @@ 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} />
@ -444,15 +417,11 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)}
onSignatureComplete={async () => handleSubmit()}
documentTitle={template.title}
fields={localFields}
fieldsValidated={fieldsValidated}
recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/>
</div>
</DocumentFlowFormContainerFooter>

View File

@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
actionVerb = 'sign',
onOpenChange,
}: DocumentSigningAuthAccountProps) => {
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
const { recipient } = useRequiredDocumentSigningAuthContext();
const { t } = useLingui();
@ -34,10 +34,8 @@ export const DocumentSigningAuthAccount = ({
try {
setIsSigningOut(true);
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
await authClient.signOut({
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
redirectPath: `/signin#email=${email}`,
});
} catch {
setIsSigningOut(false);
@ -57,28 +55,16 @@ export const DocumentSigningAuthAccount = ({
<AlertDescription>
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
<span>
{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>
{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>
)}
{/* Todo: Translate */}
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
in as <strong>{recipient.email}</strong>
</span>
)}
</AlertDescription>

View File

@ -47,8 +47,7 @@ export const DocumentSigningAuthDialog = ({
onOpenChange,
onReauthFormSubmit,
}: DocumentSigningAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
useRequiredDocumentSigningAuthContext();
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Filter out EXPLICIT_NONE from available auth types for the chooser
const validAuthTypes = availableAuthTypes.filter(
@ -169,11 +168,7 @@ export const DocumentSigningAuthDialog = ({
match({ documentAuthType: selectedAuthType, user })
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{
user: P.when(
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
),
}, // Assume all current auth methods requires them to be logged in.
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (

View File

@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentSigningAuthPageViewProps = {
email?: string;
email: string;
emailHasAccount?: boolean;
};
@ -22,18 +22,12 @@ 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,
redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`,
});
} catch {
toast({
@ -55,13 +49,9 @@ export const DocumentSigningAuthPageView = ({
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{email ? (
<Trans>
You need to be logged in as <strong>{email}</strong> to view this page.
</Trans>
) : (
<Trans>You need to be logged in to view this page.</Trans>
)}
</p>
<Button

View File

@ -24,10 +24,7 @@ type PasskeyData = {
isError: boolean;
};
type SigningAuthRecipient = Pick<
Recipient,
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
>;
type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
export type DocumentSigningAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
@ -40,7 +37,6 @@ export type DocumentSigningAuthContextValue = {
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
isAuthRedirectRequired: boolean;
isDirectTemplate?: boolean;
isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void;
passkeyData: PasskeyData;
@ -69,7 +65,6 @@ export const useRequiredDocumentSigningAuthContext = () => {
export interface DocumentSigningAuthProviderProps {
documentAuthOptions: Envelope['authOptions'];
recipient: SigningAuthRecipient;
isDirectTemplate?: boolean;
user?: SessionUser | null;
children: React.ReactNode;
}
@ -77,7 +72,6 @@ export interface DocumentSigningAuthProviderProps {
export const DocumentSigningAuthProvider = ({
documentAuthOptions: initialDocumentAuthOptions,
recipient: initialRecipient,
isDirectTemplate = false,
user,
children,
}: DocumentSigningAuthProviderProps) => {
@ -207,7 +201,6 @@ export const DocumentSigningAuthProvider = ({
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
isAuthRedirectRequired,
isDirectTemplate,
isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating,
passkeyData,

View File

@ -34,7 +34,6 @@ 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';
@ -103,8 +102,6 @@ export const DocumentSigningCompleteDialog = ({
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
@ -270,12 +267,7 @@ export const DocumentSigningCompleteDialog = ({
<Trans>Your Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked}
/>
<Input {...field} className="mt-2" placeholder={t`Enter your name`} />
</FormControl>
<FormMessage />
@ -297,7 +289,6 @@ export const DocumentSigningCompleteDialog = ({
type="email"
className="mt-2"
placeholder={t`Enter your email`}
disabled={!!field.value && isEmailLocked}
/>
</FormControl>
<FormMessage />
@ -313,6 +304,7 @@ export const DocumentSigningCompleteDialog = ({
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && (
<div className="mb-4 flex flex-col gap-4">
{/* Todo: Envelopes - Should we say "The next recipient to sign this document will be"? */}
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}

View File

@ -8,9 +8,6 @@ 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';
@ -18,8 +15,6 @@ import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
export const DocumentSigningMobileWidget = () => {
const [isExpanded, setIsExpanded] = useState(false);
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
useRequiredEnvelopeSigningContext();
@ -34,7 +29,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-[760px]">
<div className="pointer-events-auto w-full max-w-2xl">
<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">
@ -119,13 +114,6 @@ 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>

View File

@ -245,12 +245,7 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex-1">
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewer
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={recipient.token}
version="signed"
/>
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
</CardContent>
</Card>
</div>

View File

@ -22,9 +22,7 @@ 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';
@ -50,13 +48,6 @@ 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.
*
@ -86,7 +77,7 @@ export const DocumentSigningPageViewV2 = () => {
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<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="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)
@ -116,7 +107,7 @@ export const DocumentSigningPageViewV2 = () => {
/>
</div>
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
<div className="mt-6 space-y-3">
<EnvelopeSignerForm />
</div>
</div>
@ -125,7 +116,7 @@ export const DocumentSigningPageViewV2 = () => {
{/* Quick Actions. */}
{!isDirectTemplate && (
<div className="embed--Actions space-y-3 px-4">
<div className="space-y-3 px-4">
<h4 className="text-foreground text-sm font-semibold">
<Trans>Actions</Trans>
</h4>
@ -154,21 +145,10 @@ export const DocumentSigningPageViewV2 = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
{envelope.type === EnvelopeType.DOCUMENT && (
<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"
@ -184,10 +164,8 @@ export const DocumentSigningPageViewV2 = () => {
</div>
)}
<div className="embed--DocumentWidgetFooter mt-auto">
{/* Footer of left sidebar. */}
{!isEmbed && (
<div className="px-4">
<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" />
@ -195,11 +173,9 @@ export const DocumentSigningPageViewV2 = () => {
</Link>
</Button>
</div>
)}
</div>
</div>
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@ -226,11 +202,11 @@ export const DocumentSigningPageViewV2 = () => {
)}
{/* Document View */}
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
<div className="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}
/>
) : (
@ -242,20 +218,9 @@ export const DocumentSigningPageViewV2 = () => {
)}
{/* Mobile widget - Additional padding to allow users to scroll */}
<div className="block pb-28 lg:hidden">
<div className="block pb-16 md: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>

View File

@ -13,7 +13,6 @@ 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,
@ -52,11 +51,7 @@ export type EnvelopeSigningContextValue = {
setSelectedAssistantRecipientId: (_value: number | null) => void;
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
signField: (
_fieldId: number,
_value: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => Promise<Pick<Field, 'id' | 'inserted'>>;
signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise<void>;
};
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
@ -289,26 +284,21 @@ export const EnvelopeSigningProvider = ({
: null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (
fieldId: number,
fieldValue: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => {
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
console.log('insertField', fieldId, fieldValue);
// Set the field locally for direct templates.
if (isDirectTemplate) {
const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue);
return signedField;
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
return;
}
const { signedField } = await signEnvelopeField({
await signEnvelopeField({
token: envelopeData.recipient.token,
fieldId,
fieldValue,
authOptions,
authOptions: undefined,
});
return signedField;
};
const handleDirectTemplateFieldInsertion = (
@ -366,8 +356,6 @@ export const EnvelopeSigningProvider = ({
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
},
}));
return updatedField;
};
return (

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { DateTime } from 'luxon';
import {
@ -23,10 +22,9 @@ 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;
@ -36,7 +34,6 @@ export type DocumentCertificateQRViewProps = {
documentTeamUrl: string;
recipientCount?: number;
completedDate?: Date;
token: string;
};
export const DocumentCertificateQRView = ({
@ -47,7 +44,6 @@ export const DocumentCertificateQRView = ({
documentTeamUrl,
recipientCount = 0,
completedDate,
token,
}: DocumentCertificateQRViewProps) => {
const { data: documentViaUser } = trpc.document.get.useQuery({
documentId,
@ -100,19 +96,11 @@ export const DocumentCertificateQRView = ({
)}
{internalVersion === 2 ? (
<EnvelopeRenderProvider
envelope={{
envelopeItems,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
token={token}
>
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<DocumentCertificateQrV2
title={title}
recipientCount={recipientCount}
formattedDate={formattedDate}
token={token}
/>
</EnvelopeRenderProvider>
) : (
@ -131,27 +119,14 @@ export const DocumentCertificateQRView = ({
</div>
</div>
<EnvelopeDownloadDialog
envelopeId={envelopeItems[0].envelopeId}
envelopeStatus={DocumentStatus.COMPLETED}
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="w-fit">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
<ShareDocumentDownloadButton
title={title}
documentData={envelopeItems[0].documentData}
/>
</div>
<div className="mt-12 w-full">
<PDFViewer
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
/>
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</div>
</>
)}
@ -163,16 +138,14 @@ type DocumentCertificateQrV2Props = {
title: string;
recipientCount: number;
formattedDate: string;
token: string;
};
const DocumentCertificateQrV2 = ({
title,
recipientCount,
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
@ -190,24 +163,18 @@ const DocumentCertificateQrV2 = ({
</div>
</div>
<EnvelopeDownloadDialog
envelopeId={envelopeItems[0].envelopeId}
envelopeStatus={DocumentStatus.COMPLETED}
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="w-fit">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
{currentEnvelopeItem && (
<ShareDocumentDownloadButton
title={title}
documentData={currentEnvelopeItem.documentData}
/>
)}
</div>
<div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</div>
</div>
);

View File

@ -1,15 +1,10 @@
import { type ReactNode, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react';
import {
ErrorCode as DropzoneErrorCode,
ErrorCode,
type FileRejection,
useDropzone,
} from 'react-dropzone';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@ -21,26 +16,21 @@ 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 { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } 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 { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export interface EnvelopeDropZoneWrapperProps {
export interface DocumentDropZoneWrapperProps {
children: ReactNode;
type: EnvelopeType;
className?: string;
}
export const EnvelopeDropZoneWrapper = ({
children,
type,
className,
}: EnvelopeDropZoneWrapperProps) => {
const { t } = useLingui();
export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZoneWrapperProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { folderId } = useParams();
@ -57,13 +47,13 @@ export const EnvelopeDropZoneWrapper = ({
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
DEFAULT_DOCUMENT_TIME_ZONE;
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
const { quota, remaining, refreshLimits } = useLimits();
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
const onFileDrop = async (files: File[]) => {
const onFileDrop = async (file: File) => {
if (isUploadDisabled && IS_BILLING_ENABLED()) {
await navigate(`/o/${organisation.url}/settings/billing`);
return;
@ -72,68 +62,48 @@ export const EnvelopeDropZoneWrapper = ({
try {
setIsLoading(true);
const payload = {
folderId,
type,
title: files[0].name,
meta: {
timezone: userTimezone,
},
} satisfies TCreateEnvelopePayload;
const response = await putPdfFile(file);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEnvelope(formData);
const { legacyDocumentId: id } = await createDocument({
title: file.name,
documentDataId: response.id,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
folderId: folderId ?? undefined,
});
void refreshLimits();
toast({
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
description:
type === EnvelopeType.DOCUMENT
? t`Your document has been uploaded successfully.`
: t`Your template has been uploaded successfully.`,
title: _(msg`Document uploaded`),
description: _(msg`Your document has been uploaded successfully.`),
duration: 5000,
});
if (type === EnvelopeType.DOCUMENT) {
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
const pathPrefix =
type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope`,
() => msg`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => t`An error occurred during upload.`);
.otherwise(() => msg`An error occurred while uploading your document.`);
toast({
title: t`Error`,
description: errorMessage,
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
@ -147,20 +117,6 @@ export const EnvelopeDropZoneWrapper = ({
return;
}
const maxItemsReached = fileRejections.some((fileRejection) =>
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
);
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
@ -188,14 +144,14 @@ export const EnvelopeDropZoneWrapper = ({
const description = (
<>
<span className="font-medium">
<Trans>{file.name} couldn't be uploaded:</Trans>
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: t`Upload failed`,
title: _(msg`Upload failed`),
description,
duration: 5000,
variant: 'destructive',
@ -205,11 +161,17 @@ export const EnvelopeDropZoneWrapper = ({
accept: {
'application/pdf': ['.pdf'],
},
multiple: true,
//disabled: isUploadDisabled,
multiple: false,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
maxFiles: maximumEnvelopeItemCount,
onDrop: (files) => void onFileDrop(files),
onDropRejected: onFileDropRejected,
onDrop: ([acceptedFile]) => {
if (acceptedFile) {
void onFileDrop(acceptedFile);
}
},
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
noClick: true,
noDragEventsBubbling: true,
});
@ -223,11 +185,7 @@ export const EnvelopeDropZoneWrapper = ({
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-foreground text-2xl font-semibold">
{type === EnvelopeType.DOCUMENT ? (
<Trans>Upload Document</Trans>
) : (
<Trans>Upload Template</Trans>
)}
</h2>
<p className="text-muted-foreground text-md mt-4">
@ -262,7 +220,7 @@ export const EnvelopeDropZoneWrapper = ({
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading</Trans>
<Trans>Uploading document...</Trans>
</p>
</div>
</div>

View File

@ -441,10 +441,9 @@ export const DocumentEditForm = ({
>
<CardContent className="p-2">
<PDFViewer
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={undefined}
version="signed"
key={document.documentData.id}
documentData={document.documentData}
document={document}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>

View File

@ -1,14 +1,18 @@
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';
@ -19,6 +23,9 @@ 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;
@ -30,6 +37,25 @@ 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,
@ -69,7 +95,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
</Link>
</Button>
))
.with({ isComplete: true }, () => (
.with({ isComplete: true, internalVersion: 2 }, () => (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
@ -83,5 +109,11 @@ 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);
};

View File

@ -1,5 +1,6 @@
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';
@ -15,11 +16,13 @@ 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,
@ -64,6 +67,64 @@ 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 (
@ -86,6 +147,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</DropdownMenuItem>
)}
{envelope.internalVersion === 2 ? (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
@ -100,6 +162,21 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</DropdownMenuItem>
}
/>
) : (
<>
{isComplete && (
<DropdownMenuItem onClick={onDownloadClick}>
<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>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem asChild>
<Link to={`${documentsPath}/${envelope.id}/logs`}>
@ -173,8 +250,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
{isDuplicateDialogOpen && (
<DocumentDuplicateDialog
id={envelope.id}
token={recipient?.token}
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>

View File

@ -7,7 +7,6 @@ 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;
@ -41,10 +40,6 @@ 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]);

View File

@ -3,7 +3,6 @@ import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@ -14,11 +13,11 @@ 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 { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import {
Tooltip,
TooltipContent,
@ -29,11 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type DocumentUploadButtonLegacyProps = {
export type DocumentUploadButtonProps = {
className?: string;
};
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => {
export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
@ -74,20 +73,14 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
try {
setIsLoading(true);
const payload = {
const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
title: file.name,
folderId: folderId ?? undefined,
meta: {
documentDataId: response.id,
timezone: userTimezone,
},
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
folderId: folderId ?? undefined,
});
void refreshLimits();
@ -147,14 +140,12 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
<Tooltip>
<TooltipTrigger asChild>
<div>
<DocumentUploadButtonPrimitive
<DocumentDropzone
loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={async (files) => onFileDrop(files[0])}
onDropRejected={onFileDropRejected}
type={EnvelopeType.DOCUMENT}
internalVersion="1"
/>
</div>
</TooltipTrigger>

View File

@ -14,11 +14,11 @@ 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 { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import {
Tooltip,
TooltipContent,
@ -78,24 +78,35 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try {
setIsLoading(true);
const payload = {
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({
folderId,
type,
title: files[0].name,
items: envelopeItemsToCreate,
meta: {
timezone: userTimezone,
},
} 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) => {
}).catch((error) => {
console.error(error);
throw error;
@ -175,14 +186,13 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
<Tooltip>
<TooltipTrigger asChild>
<div>
<DocumentUploadButton
<DocumentDropzone
loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
type={type}
internalVersion="2"
type="envelope"
maxFiles={maximumEnvelopeItemCount}
/>
</div>

View File

@ -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, setRenderError } = useCurrentEnvelopeRender();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const interactiveTransformer = useRef<Transformer | null>(null);
@ -103,6 +103,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldUpdates.height = fieldPageHeight;
}
// Todo: envelopes Use id
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
// Select the field if it is not already selected.
@ -113,7 +114,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
pageLayer.current?.batchDraw();
};
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
return;
}
@ -159,15 +160,6 @@ 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.
*/
@ -616,14 +608,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
transform: 'translateX(-50%)',
zIndex: 50,
}}
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
className="text-muted-foreground grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
>
{fieldButtonList.map((field) => (
<button
key={field.type}
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
className="hover:text-foreground col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100"
>
{t(field.name)}
</button>

View File

@ -27,8 +27,7 @@ import type {
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, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
@ -113,34 +112,9 @@ export const EnvelopeEditorFieldsPage = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<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>
)}
<div className="mt-4 flex h-full justify-center p-4">
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy
renderer="editor"
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
/>
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
@ -156,7 +130,7 @@ export const EnvelopeEditorFieldsPage = () => {
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
{currentEnvelopeItem && (
<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">
@ -164,6 +138,19 @@ export const EnvelopeEditorFieldsPage = () => {
<Trans>Selected Recipient</Trans>
</h3>
{envelope.recipients.length === 0 ? (
<Alert variant="warning">
<AlertDescription className="flex flex-col gap-2">
<Trans>You need at least one recipient to add fields</Trans>
<Link to={`${relativePath.editorPath}`} className="text-sm">
<p>
<Trans>Click here to add a recipient</Trans>
</p>
</Link>
</AlertDescription>
</Alert>
) : (
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
@ -173,6 +160,7 @@ export const EnvelopeEditorFieldsPage = () => {
className="w-full"
align="end"
/>
)}
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (

View File

@ -1,20 +1,10 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { lazy, useEffect, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
import { FieldType, SigningStatus } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { ConstructionIcon, FileTextIcon } from 'lucide-react';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-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 { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
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';
@ -25,169 +15,15 @@ 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, fields } = useCurrentEnvelopeRender();
const { currentEnvelopeItem } = 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.
*/
@ -195,20 +31,7 @@ export const EnvelopeEditorPreviewPage = () => {
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
}, []);
// Override the parent renderer provider so we can inject custom fields.
return (
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
}))}
overrideSettings={{
mode: 'export',
}}
>
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
@ -225,11 +48,23 @@ export const EnvelopeEditorPreviewPage = () => {
</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
renderer="editor"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
@ -243,6 +78,7 @@ export const EnvelopeEditorPreviewPage = () => {
)}
</div>
</div>
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && false && (
@ -258,9 +94,7 @@ export const EnvelopeEditorPreviewPage = () => {
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Preview what the signed document will look like with placeholder data
</Trans>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
@ -334,6 +168,5 @@ export const EnvelopeEditorPreviewPage = () => {
</div>
)}
</div>
</EnvelopeRenderProvider>
);
};

View File

@ -212,7 +212,7 @@ export const EnvelopeEditorRecipientForm = () => {
);
const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
const canRecipientBeModified = (recipientId?: number) => {
@ -482,46 +482,30 @@ export const EnvelopeEditorRecipientForm = () => {
const { data } = validatedFormValues;
// 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) => {
data.signers.length !== recipients.length ||
data.signers.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)
!isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth)
);
});
if (hasSignersChanged) {
setRecipientsDebounced(envelopeRecipients);
setRecipientsDebounced(validatedFormValues.data.signers);
}
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {

View File

@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({
const { t, i18n } = useLingui();
const { toast } = useToast();
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
const { envelope } = useCurrentEnvelopeEditor();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@ -186,12 +186,14 @@ export const EnvelopeEditorSettingsDialog = ({
documentAuth: envelope.authOptions,
});
const createDefaultValues = () => {
return {
externalId: envelope.externalId || '',
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: {
externalId: envelope.externalId || '', // Todo: String or undefined?
visibility: envelope.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
subject: envelope.documentMeta.subject ?? '',
message: envelope.documentMeta.message ?? '',
@ -208,14 +210,11 @@ export const EnvelopeEditorSettingsDialog = ({
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
},
};
};
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: createDefaultValues(),
},
});
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
const envelopeHasBeenSent =
envelope.type === EnvelopeType.DOCUMENT &&
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
@ -230,6 +229,7 @@ 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,7 +240,9 @@ export const EnvelopeEditorSettingsDialog = ({
.safeParse(data.globalAccessAuth);
try {
await updateEnvelopeAsync({
await updateEnvelope({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: {
externalId: data.externalId || null,
visibility: data.visibility,
@ -295,7 +297,7 @@ export const EnvelopeEditorSettingsDialog = ({
]);
useEffect(() => {
form.reset(createDefaultValues());
form.reset();
setActiveTab('general');
}, [open, form]);
@ -321,7 +323,7 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */}
<div className="bg-accent/20 flex w-80 flex-col border-r">
<div className="flex w-80 flex-col border-r bg-gray-50">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Document Settings</DialogTitle>
</DialogHeader>

View File

@ -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,
@ -49,7 +49,7 @@ export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
@ -67,8 +67,8 @@ export const EnvelopeEditorUploadPage = () => {
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
trpc.envelope.item.createMany.useMutation({
onSuccess: ({ data }) => {
const createdEnvelopes = data.filter(
onSuccess: (data) => {
const createdEnvelopes = data.createdEnvelopeItems.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.find((item) => item.id === originalItem.id);
const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id);
if (updatedItem) {
return {
@ -114,19 +114,36 @@ export const EnvelopeEditorUploadPage = () => {
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
const payload = {
envelopeId: envelope.id,
} satisfies TCreateEnvelopeItemsPayload;
const result = await Promise.all(
files.map(async (file, index) => {
try {
const response = await putPdfFile(file);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', 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 { data } = await createEnvelopeItems(formData).catch((error) => {
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { createdEnvelopeItems } = await createEnvelopeItems({
envelopeId: envelope.id,
items: envelopeItemsToCreate,
}).catch((error) => {
console.error(error);
// Set error state on files in batch upload.
@ -148,7 +165,7 @@ export const EnvelopeEditorUploadPage = () => {
);
return filteredFiles.concat(
data.map((item) => ({
createdEnvelopeItems.map((item) => ({
id: item.id,
envelopeItemId: item.id,
title: item.title,
@ -165,17 +182,9 @@ export const EnvelopeEditorUploadPage = () => {
const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
});
// Reset editor fields.
editorFields.resetForm(fieldsWithoutDeletedItem);
};
/**
@ -194,6 +203,7 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(items);
};
// Todo: Envelopes - Sync into envelopes data
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({
envelopeId: envelope.id,

View File

@ -29,7 +29,7 @@ export const EnvelopeItemSelector = ({
{...buttonProps}
>
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
}`}
>

View File

@ -1,7 +1,6 @@
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@ -9,24 +8,11 @@ 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 {
envelopeStatus,
currentEnvelopeItem,
fields,
recipients,
getRecipientColorKey,
setRenderError,
overrideSettings,
} = useCurrentEnvelopeRender();
const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender();
const {
stage,
@ -42,81 +28,44 @@ export default function EnvelopeGenericPageRenderer() {
const { _className, scale } = pageContext;
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return [];
}
return fields
.filter(
const localPageFields = useMemo(
() =>
fields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
)
.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
if (!recipient) {
throw new Error(`Recipient not found for field ${field.id}`);
}
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
return {
...field,
inserted: isInserted,
customText: isInserted ? field.customText : '',
recipient,
};
})
.filter(
({ inserted, fieldMeta, recipient }) =>
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly,
),
[fields, pageContext.pageNumber],
);
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
const fieldTranslations = getClientSideFieldTranslations(i18n);
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,
fieldMeta: field.fieldMeta,
signature: {
signatureImageAsBase64: '',
typedSignature: fieldTranslations.SIGNATURE,
},
},
translations: fieldTranslations,
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: overrideSettings?.mode ?? 'edit',
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.
*/
@ -164,16 +113,6 @@ 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>

View File

@ -8,8 +8,6 @@ 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() {
@ -27,8 +25,6 @@ export default function EnvelopeSignerForm() {
setSelectedAssistantRecipientId,
} = useRequiredEnvelopeSigningContext();
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const hasSignatureField = useMemo(() => {
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
}, [recipientFields]);
@ -41,7 +37,7 @@ export default function EnvelopeSignerForm() {
if (recipient.role === RecipientRole.ASSISTANT) {
return (
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
<fieldset className="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()}
@ -105,8 +101,7 @@ export default function EnvelopeSignerForm() {
id="full-name"
className="bg-background mt-2"
value={fullName}
disabled={isNameLocked}
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>

View File

@ -16,7 +16,6 @@ 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';
@ -29,7 +28,7 @@ export const EnvelopeSignerHeader = () => {
useRequiredEnvelopeSigningContext();
return (
<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">
<nav className="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">
@ -73,7 +72,7 @@ export const EnvelopeSignerHeader = () => {
</div>
{/* Right side - Desktop content */}
<div className="hidden items-center space-x-2 lg:flex">
<div className="hidden items-center space-x-2 md:flex">
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
<Plural
one="1 Field Remaining"
@ -86,7 +85,7 @@ export const EnvelopeSignerHeader = () => {
</div>
{/* Mobile Actions button */}
<div className="flex-shrink-0 lg:hidden">
<div className="flex-shrink-0 md:hidden">
<MobileDropdownMenu />
</div>
</nav>
@ -96,8 +95,6 @@ export const EnvelopeSignerHeader = () => {
const MobileDropdownMenu = () => {
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
const { allowDocumentRejection } = useEmbedSigningContext() || {};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -122,7 +119,7 @@ const MobileDropdownMenu = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
{envelope.type === EnvelopeType.DOCUMENT && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}

View File

@ -1,14 +1,7 @@
import { useEffect, useMemo } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import {
type Field,
FieldType,
type Recipient,
RecipientRole,
type Signature,
SigningStatus,
} from '@prisma/client';
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern';
@ -17,22 +10,15 @@ 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 type { TEnvelope } from '@documenso/lib/types/envelope';
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 { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
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';
@ -42,28 +28,20 @@ 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';
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeSignerPageRenderer() {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { i18n } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const {
envelopeData,
recipient,
recipientFields,
recipientFieldsRemaining,
showPendingFieldTooltip,
signField: signFieldInternal,
signField,
email,
setEmail,
fullName,
@ -75,8 +53,6 @@ export default function EnvelopeSignerPageRenderer() {
isDirectTemplate,
} = useRequiredEnvelopeSigningContext();
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const {
stage,
pageLayer,
@ -104,37 +80,7 @@ export default function EnvelopeSignerPageRenderer() {
);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
/**
* Returns fields that have been fully signed by other recipients for this specific
* page.
*/
const localPageOtherRecipientFields = useMemo((): GenericLocalField[] => {
const signedRecipients = envelope.recipients.filter(
(recipient) => recipient.signingStatus === SigningStatus.SIGNED,
);
return signedRecipients.flatMap((recipient) => {
return recipient.fields
.filter(
(field) =>
field.page === pageContext.pageNumber &&
field.envelopeItemId === currentEnvelopeItem?.id &&
(field.inserted || field.fieldMeta?.readOnly),
)
.map((field) => ({
...field,
recipient: {
id: recipient.id,
name: recipient.name,
email: recipient.email,
signingStatus: recipient.signingStatus,
role: recipient.role,
},
}));
});
}, [envelope.recipients, pageContext.pageNumber]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@ -291,7 +237,7 @@ export default function EnvelopeSignerPageRenderer() {
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
await signField(field.id, payload); // Todo: Envelopes - Handle errors
}
if (payload?.value) {
@ -372,6 +318,7 @@ export default function EnvelopeSignerPageRenderer() {
* SIGNATURE FIELD.
*/
.with({ type: FieldType.SIGNATURE }, (field) => {
// Todo: Envelopes - Reauth
handleSignatureFieldClick({
field,
signature,
@ -382,21 +329,11 @@ export default function EnvelopeSignerPageRenderer() {
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
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);
}
if (payload?.value) {
setSignature(payload.value);
}
})
.finally(() => {
@ -410,92 +347,15 @@ 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 renderFields = () => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
// Render current recipient fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
// Render other recipient signed and inserted fields.
for (const field of localPageOtherRecipientFields) {
try {
renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.id.toString(),
...field,
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
fieldMeta: field.fieldMeta,
},
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: 'readOnly',
editable: false,
mode: 'sign',
});
} catch (err) {
console.error('Unable to render one or more fields belonging to other recipients.');
console.error(err);
}
}
};
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) => {
renderFields();
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
}
currentPageLayer.batchDraw();
};
@ -507,7 +367,10 @@ export default function EnvelopeSignerPageRenderer() {
return;
}
renderFields();
localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
});
pageLayer.current.batchDraw();
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
@ -523,7 +386,9 @@ export default function EnvelopeSignerPageRenderer() {
// Rerender the whole page.
pageLayer.current.destroyChildren();
renderFields();
localPageFields.forEach((field) => {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
});
pageLayer.current.batchDraw();
}, [selectedAssistantRecipient]);
@ -550,15 +415,6 @@ export default function EnvelopeSignerPageRenderer() {
</EnvelopeFieldToolTip>
)}
{localPageOtherRecipientFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
field={field}
showFieldStatus={true}
showRecipientTooltip={true}
/>
))}
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>

View File

@ -2,19 +2,16 @@ import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
import { useNavigate, 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';
@ -22,9 +19,8 @@ export const EnvelopeSignerCompleteDialog = () => {
const navigate = useNavigate();
const analytics = useAnalytics();
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { t } = useLingui();
const [searchParams] = useSearchParams();
@ -41,8 +37,6 @@ export const EnvelopeSignerCompleteDialog = () => {
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
const { mutateAsync: completeDocument, isPending } =
trpc.recipient.completeDocumentWithToken.useMutation();
@ -74,13 +68,14 @@ export const EnvelopeSignerCompleteDialog = () => {
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => {
try {
await completeDocument({
const payload = {
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
authOptions: accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
};
await completeDocument(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
@ -88,39 +83,11 @@ export const EnvelopeSignerCompleteDialog = () => {
timestamp: new Date().toISOString(),
});
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;
}
};
/**
@ -138,12 +105,8 @@ export const EnvelopeSignerCompleteDialog = () => {
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
if (!recipient.directToken) {
throw new Error('Recipient direct token is required');
}
const { token } = await createDocumentFromDirectTemplate({
directTemplateToken: recipient.directToken, // The direct template token is inserted into the recipient token for ease of use.
directTemplateToken: recipient.token, // The direct template token is inserted into the recipient token for ease of use.
directTemplateExternalId,
directRecipientName: recipientDetails?.name || fullName,
directRecipientEmail: recipientDetails?.email || email,
@ -164,36 +127,22 @@ 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;
}
};

View File

@ -16,11 +16,11 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy';
import { DocumentUploadButton } from '~/components/general/document/document-upload-button';
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeUploadButton } from '../envelope/envelope-upload-button';
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
export type FolderGridProps = {
type: FolderType;
@ -98,12 +98,14 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
</div>
<div className="flex gap-4 sm:flex-row sm:justify-end">
{organisation.organisationClaim.flags.allowEnvelopes && (
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
)}
{type === FolderType.DOCUMENT ? (
<DocumentUploadButtonLegacy /> // If you delete this, delete the component as well.
<DocumentUploadButton />
) : (
<TemplateCreateDialog folderId={parentId ?? undefined} /> // If you delete this, delete the component as well.
<TemplateCreateDialog folderId={parentId ?? undefined} />
)}
<FolderCreateDialog type={type} />

View File

@ -0,0 +1,49 @@
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;
};
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>
);
};

View File

@ -0,0 +1,167 @@
import { type ReactNode, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
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 { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export interface TemplateDropZoneWrapperProps {
children: ReactNode;
className?: string;
}
export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const team = useCurrentTeam();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
const documentData = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined,
});
toast({
title: _(msg`Template uploaded`),
description: _(
msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Please try again later.`),
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const onFileDropRejected = (fileRejections: FileRejection[]) => {
if (!fileRejections.length) {
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: _(msg`Upload failed`),
description,
duration: 5000,
variant: 'destructive',
});
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
//disabled: isUploadDisabled,
multiple: false,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
onDrop: ([acceptedFile]) => {
if (acceptedFile) {
void onFileDrop(acceptedFile);
}
},
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
noClick: true,
noDragEventsBubbling: true,
});
return (
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-foreground text-2xl font-semibold">
<Trans>Upload Template</Trans>
</h2>
<p className="text-muted-foreground text-md mt-4">
<Trans>Drag and drop your PDF file here</Trans>
</p>
</div>
</div>
)}
{isLoading && (
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading template...</Trans>
</p>
</div>
</div>
)}
</div>
);
};

View File

@ -313,10 +313,8 @@ export const TemplateEditForm = ({
>
<CardContent className="p-2">
<PDFViewer
key={template.envelopeItems[0].id}
envelopeItem={template.envelopeItems[0]}
token={undefined}
version="signed"
key={templateDocumentData.id}
documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>

View File

@ -7,13 +7,11 @@ 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'>;
@ -45,10 +43,6 @@ 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]);

View File

@ -2,49 +2,40 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
import { Link } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { DateRange } from '@documenso/lib/types/search-params';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
export type OrganisationOverview = {
id: string;
export type SigningVolume = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
customerId: string;
subscriptionStatus?: string;
isActive?: boolean;
teamCount?: number;
memberCount?: number;
planId: string;
};
type OrganisationOverviewTableProps = {
organisations: OrganisationOverview[];
type LeaderboardTableProps = {
signingVolume: SigningVolume[];
totalPages: number;
perPage: number;
page: number;
sortBy: 'name' | 'createdAt' | 'signingVolume';
sortOrder: 'asc' | 'desc';
dateRange: DateRange;
};
export const AdminOrganisationOverviewTable = ({
organisations,
export const AdminLeaderboardTable = ({
signingVolume,
totalPages,
perPage,
page,
sortBy,
sortOrder,
dateRange,
}: OrganisationOverviewTableProps) => {
}: LeaderboardTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
@ -76,16 +67,17 @@ export const AdminOrganisationOverviewTable = ({
cell: ({ row }) => {
return (
<div>
<Link
className="hover:underline"
to={`/admin/organisation-insights/${row.original.id}?dateRange=${dateRange}`}
<a
className="text-primary underline"
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
target="_blank"
>
{row.getValue('name')}
</Link>
</a>
</div>
);
},
size: 240,
size: 250,
},
{
header: () => (
@ -93,9 +85,7 @@ export const AdminOrganisationOverviewTable = ({
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('signingVolume')}
>
<span className="whitespace-nowrap">
<Trans>Document Volume</Trans>
</span>
{_(msg`Signing Volume`)}
{sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
@ -109,23 +99,6 @@ export const AdminOrganisationOverviewTable = ({
),
accessorKey: 'signingVolume',
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
size: 160,
},
{
header: () => {
return <Trans>Teams</Trans>;
},
accessorKey: 'teamCount',
cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>,
size: 120,
},
{
header: () => {
return <Trans>Members</Trans>;
},
accessorKey: 'memberCount',
cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>,
size: 160,
},
{
header: () => {
@ -134,9 +107,7 @@ export const AdminOrganisationOverviewTable = ({
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('createdAt')}
>
<span className="whitespace-nowrap">
<Trans>Created</Trans>
</span>
{_(msg`Created`)}
{sortBy === 'createdAt' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
@ -150,11 +121,10 @@ export const AdminOrganisationOverviewTable = ({
);
},
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.original.createdAt)),
size: 120,
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<OrganisationOverview>[];
}, [sortOrder, sortBy, dateRange]);
] satisfies DataTableColumnDef<SigningVolume>[];
}, [sortOrder, sortBy]);
useEffect(() => {
startTransition(() => {
@ -199,13 +169,13 @@ export const AdminOrganisationOverviewTable = ({
<Input
className="my-6 flex flex-row gap-4"
type="text"
placeholder={_(msg`Search by organisation name`)}
placeholder={_(msg`Search by name or email`)}
value={searchString}
onChange={handleChange}
/>
<DataTable
columns={columns}
data={organisations}
data={signingVolume}
perPage={perPage}
currentPage={page}
totalPages={totalPages}

View File

@ -93,31 +93,13 @@ export const AdminOrganisationsTable = ({
),
},
{
id: 'role',
header: t`Role`,
header: t`Status`,
cell: ({ row }) => (
<Badge variant="neutral">
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
</Badge>
),
},
{
id: 'billingStatus',
header: t`Status`,
cell: ({ row }) => {
const subscription = row.original.subscription;
const isPaid = subscription && subscription.status === 'ACTIVE';
return (
<div
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{isPaid ? t`Paid` : t`Free`}
</div>
);
},
},
{
header: t`Subscription`,
cell: ({ row }) =>
@ -186,7 +168,7 @@ export const AdminOrganisationsTable = ({
onPaginationChange={onPaginationChange}
columnVisibility={{
owner: showOwnerColumn,
role: memberUserId !== undefined,
status: memberUserId !== undefined,
}}
error={{
enable: isLoadingError,

View File

@ -1,14 +1,19 @@
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';
@ -20,6 +25,8 @@ export type DocumentsTableActionButtonProps = {
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const team = useCurrentTeam();
@ -37,6 +44,39 @@ 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;
@ -94,7 +134,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
<Trans>View</Trans>
</Button>
))
.with({ isComplete: true }, () => (
.with({ isComplete: true, internalVersion: 2 }, () => (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
@ -107,5 +147,11 @@ 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>);
};

View File

@ -10,6 +10,7 @@ import {
Download,
Edit,
EyeIcon,
FileDown,
FolderInput,
Loader,
MoreHorizontal,
@ -19,10 +20,12 @@ 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,
@ -31,6 +34,7 @@ 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';
@ -52,6 +56,7 @@ export const DocumentsTableActionDropdown = ({
const { user } = useSession();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -71,6 +76,58 @@ 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 (
@ -121,6 +178,7 @@ export const DocumentsTableActionDropdown = ({
</Link>
</DropdownMenuItem>
{row.internalVersion === 2 ? (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
@ -134,6 +192,19 @@ export const DocumentsTableActionDropdown = ({
</DropdownMenuItem>
}
/>
) : (
<>
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</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" />
@ -202,8 +273,7 @@ export const DocumentsTableActionDropdown = ({
/>
<DocumentDuplicateDialog
id={row.envelopeId}
token={recipient?.token}
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>

View File

@ -10,9 +10,11 @@ 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';
@ -26,7 +28,6 @@ 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 = {
@ -198,6 +199,28 @@ 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;
@ -207,7 +230,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending,
isComplete,
isSigned,
internalVersion: row.internalVersion,
})
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
@ -241,17 +263,10 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
</Button>
))
.with({ isComplete: true }, () => (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
token={recipient?.token}
trigger={
<Button className="w-32">
<Button className="w-32" onClick={onDownloadClick}>
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
}
/>
))
.otherwise(() => <div></div>);
};

View File

@ -1,287 +0,0 @@
import { useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Building2, Loader, TrendingUp, Users } from 'lucide-react';
import { Link } from 'react-router';
import { useNavigation } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { OrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
import type { DateRange } from '@documenso/lib/types/search-params';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { DateRangeFilter } from '~/components/filters/date-range-filter';
import { DocumentStatus } from '~/components/general/document/document-status';
type OrganisationInsightsTableProps = {
insights: OrganisationDetailedInsights;
page: number;
perPage: number;
dateRange: DateRange;
view: 'teams' | 'users' | 'documents';
};
export const OrganisationInsightsTable = ({
insights,
page,
perPage,
dateRange,
view,
}: OrganisationInsightsTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
const navigation = useNavigation();
const updateSearchParams = useUpdateSearchParams();
const isLoading = isPending || navigation.state === 'loading';
const onPaginationChange = (newPage: number, newPerPage: number) => {
startTransition(() => {
updateSearchParams({
page: newPage,
perPage: newPerPage,
});
});
};
const handleViewChange = (newView: 'teams' | 'users' | 'documents') => {
startTransition(() => {
updateSearchParams({
view: newView,
page: 1,
});
});
};
const teamsColumns = [
{
header: _(msg`Team Name`),
accessorKey: 'name',
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
size: 240,
},
{
header: _(msg`Members`),
accessorKey: 'memberCount',
cell: ({ row }) => Number(row.getValue('memberCount')),
size: 120,
},
{
header: _(msg`Documents`),
accessorKey: 'documentCount',
cell: ({ row }) => Number(row.getValue('documentCount')),
size: 140,
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 160,
},
] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
const usersColumns = [
{
header: () => <span className="whitespace-nowrap">{_(msg`Name`)}</span>,
accessorKey: 'name',
cell: ({ row }) => (
<Link
className="block max-w-full truncate hover:underline"
to={`/admin/users/${row.original.id}`}
>
{(row.getValue('name') as string) || (row.getValue('email') as string)}
</Link>
),
size: 220,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Email`)}</span>,
accessorKey: 'email',
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('email')}</span>,
size: 260,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Documents Created`)}</span>,
accessorKey: 'documentCount',
cell: ({ row }) => Number(row.getValue('documentCount')),
size: 180,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Documents Completed`)}</span>,
accessorKey: 'signedDocumentCount',
cell: ({ row }) => Number(row.getValue('signedDocumentCount')),
size: 180,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Joined`)}</span>,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 160,
},
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
const documentsColumns = [
{
header: () => <span className="whitespace-nowrap">{_(msg`Title`)}</span>,
accessorKey: 'title',
cell: ({ row }) => (
<Link
className="block max-w-[200px] truncate hover:underline"
to={`/admin/documents/${row.original.id}`}
title={row.getValue('title') as string}
>
{row.getValue('title')}
</Link>
),
size: 200,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Status`)}</span>,
accessorKey: 'status',
cell: ({ row }) => (
<DocumentStatus status={row.getValue('status') as ExtendedDocumentStatus} />
),
size: 120,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Team`)}</span>,
accessorKey: 'teamName',
cell: ({ row }) => (
<span className="block max-w-[150px] truncate" title={row.getValue('teamName') as string}>
{row.getValue('teamName')}
</span>
),
size: 150,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Created`)}</span>,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 140,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Completed`)}</span>,
accessorKey: 'completedAt',
cell: ({ row }) => {
const completedAt = row.getValue('completedAt') as Date | null;
return completedAt ? i18n.date(new Date(completedAt)) : '-';
},
size: 140,
},
] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[];
const getCurrentData = (): unknown[] => {
switch (view) {
case 'teams':
return insights.teams;
case 'users':
return insights.users;
case 'documents':
return insights.documents;
default:
return [];
}
};
const getCurrentColumns = (): DataTableColumnDef<unknown>[] => {
switch (view) {
case 'teams':
return teamsColumns as unknown as DataTableColumnDef<unknown>[];
case 'users':
return usersColumns as unknown as DataTableColumnDef<unknown>[];
case 'documents':
return documentsColumns as unknown as DataTableColumnDef<unknown>[];
default:
return [];
}
};
return (
<div className="relative">
{insights.summary && (
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3">
<SummaryCard icon={Building2} title={_(msg`Teams`)} value={insights.summary.totalTeams} />
<SummaryCard icon={Users} title={_(msg`Members`)} value={insights.summary.totalMembers} />
<SummaryCard
icon={TrendingUp}
title={_(msg`Documents Completed`)}
value={insights.summary.volumeThisPeriod}
/>
</div>
)}
<div className="mb-6 flex items-center justify-between">
<div className="flex gap-2">
<Button
variant={view === 'teams' ? 'default' : 'outline'}
onClick={() => handleViewChange('teams')}
disabled={isLoading}
>
{_(msg`Teams`)}
</Button>
<Button
variant={view === 'users' ? 'default' : 'outline'}
onClick={() => handleViewChange('users')}
disabled={isLoading}
>
{_(msg`Users`)}
</Button>
<Button
variant={view === 'documents' ? 'default' : 'outline'}
onClick={() => handleViewChange('documents')}
disabled={isLoading}
>
{_(msg`Documents`)}
</Button>
</div>
<DateRangeFilter currentRange={dateRange} />
</div>
<div className={view === 'documents' ? 'overflow-hidden' : undefined}>
<DataTable<unknown, unknown>
columns={getCurrentColumns()}
data={getCurrentData()}
perPage={perPage}
currentPage={page}
totalPages={insights.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};
const SummaryCard = ({
icon: Icon,
title,
value,
subtitle,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
value: number;
subtitle?: string;
}) => (
<div className="bg-card flex items-start gap-x-2 rounded-lg border px-4 py-3">
<Icon className="text-muted-foreground h-4 w-4 items-start" />
<div className="-mt-0.5 space-y-2">
<p className="text-muted-foreground text-sm font-medium">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
</div>
</div>
);

View File

@ -56,14 +56,7 @@ export const UserOrganisationsTable = () => {
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={
<span className="text-foreground/80 font-semibold">
{isPersonalLayoutMode
? _(
msg({
message: `Personal`,
context: `Personal organisation (adjective)`,
}),
)
: row.original.name}
{isPersonalLayoutMode ? _(msg`Personal`) : row.original.name}
</span>
}
secondaryText={

View File

@ -88,12 +88,14 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
? {
heading: msg`Organisation not found`,
subHeading: msg`404 Organisation not found`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never
existed.`,
}
: {
heading: msg`Team not found`,
subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The team you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
@ -114,7 +116,7 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && !hideHeader && <AppBanner banner={banner} />}
{banner && <AppBanner banner={banner} />}
{!hideHeader && <Header />}

View File

@ -114,13 +114,13 @@ export default function AdminLayout() {
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/organisation-insights') && 'bg-secondary',
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/organisation-insights">
<Link to="/admin/leaderboard">
<Trophy className="mr-2 h-5 w-5" />
<Trans>Organisation Insights</Trans>
<Trans>Leaderboard</Trans>
</Link>
</Button>
@ -128,7 +128,7 @@ export default function AdminLayout() {
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/site-settings') && 'bg-secondary',
pathname?.startsWith('/admin/banner') && 'bg-secondary',
)}
asChild
>

View File

@ -0,0 +1,77 @@
import { Trans } from '@lingui/react/macro';
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
import {
AdminLeaderboardTable,
type SigningVolume,
} from '~/components/tables/admin-leaderboard-table';
import type { Route } from './+types/leaderboard';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
| 'asc'
| 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortBy = (
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
) as 'name' | 'createdAt' | 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const { leaderboard, totalPages } = await getSigningVolume({
search,
page,
perPage,
sortBy,
sortOrder,
});
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
...item,
name: item.name || '',
createdAt: item.createdAt || new Date(),
}));
return {
signingVolume: typedSigningVolume,
totalPages,
page,
perPage,
sortBy,
sortOrder,
};
}
export default function Leaderboard({ loaderData }: Route.ComponentProps) {
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
return (
<div>
<div className="flex items-center">
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
</div>
<div className="mt-8">
<AdminLeaderboardTable
signingVolume={signingVolume}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
/>
</div>
</div>
);
}

View File

@ -1,59 +0,0 @@
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
import type { DateRange } from '@documenso/lib/types/search-params';
import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
import type { Route } from './+types/organisation-insights.$id';
export async function loader({ params, request }: Route.LoaderArgs) {
const { id } = params;
const url = new URL(request.url);
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents';
const [insights, organisation] = await Promise.all([
getOrganisationDetailedInsights({
organisationId: id,
page,
perPage,
dateRange,
view,
}),
getAdminOrganisation({ organisationId: id }),
]);
return {
organisationId: id,
organisationName: organisation.name,
insights,
page,
perPage,
dateRange,
view,
};
}
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
const { insights, page, perPage, dateRange, view, organisationName } = loaderData;
return (
<div>
<div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold">{organisationName}</h2>
</div>
<div className="mt-8">
<OrganisationInsightsTable
insights={insights}
page={page}
perPage={perPage}
dateRange={dateRange}
view={view}
/>
</div>
</div>
);
}

View File

@ -1,91 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume';
import type { DateRange } from '@documenso/lib/types/search-params';
import { DateRangeFilter } from '~/components/filters/date-range-filter';
import {
AdminOrganisationOverviewTable,
type OrganisationOverview,
} from '~/components/tables/admin-organisation-overview-table';
import type { Route } from './+types/organisation-insights._index';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
const isSortOrder = (value: string): value is 'asc' | 'desc' =>
value === 'asc' || value === 'desc';
const isSortBy = (value: string): value is 'name' | 'createdAt' | 'signingVolume' =>
value === 'name' || value === 'createdAt' || value === 'signingVolume';
const sortOrder: 'asc' | 'desc' = isSortOrder(rawSortOrder) ? rawSortOrder : 'desc';
const sortBy: 'name' | 'createdAt' | 'signingVolume' = isSortBy(rawSortBy)
? rawSortBy
: 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
const { organisations, totalPages } = await getOrganisationInsights({
search,
page,
perPage,
sortBy,
sortOrder,
dateRange,
});
const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
id: String(item.id),
name: item.name || '',
signingVolume: item.signingVolume,
createdAt: item.createdAt || new Date(),
customerId: item.customerId || '',
subscriptionStatus: item.subscriptionStatus,
teamCount: item.teamCount || 0,
memberCount: item.memberCount || 0,
}));
return {
organisations: typedOrganisations,
totalPages,
page,
perPage,
sortBy,
sortOrder,
dateRange,
};
}
export default function Organisations({ loaderData }: Route.ComponentProps) {
const { organisations, totalPages, page, perPage, sortBy, sortOrder, dateRange } = loaderData;
return (
<div>
<div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold">
<Trans>Organisation Insights</Trans>
</h2>
<DateRangeFilter currentRange={dateRange} />
</div>
<div className="mt-8">
<AdminOrganisationOverviewTable
organisations={organisations}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
dateRange={dateRange}
/>
</div>
</div>
);
}

View File

@ -34,7 +34,6 @@ 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';
@ -72,6 +71,23 @@ 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 [
{
@ -104,24 +120,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
{
header: t`Actions`,
cell: ({ row }) => {
const isOwner = row.original.userId === organisation?.ownerUserId;
return (
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<AdminOrganisationMemberUpdateDialog
trigger={
<Button variant="outline">
<Trans>Update role</Trans>
</Button>
<Button
variant="outline"
disabled={row.original.userId === organisation?.ownerUserId}
loading={isPromotingToOwner}
onClick={async () =>
promoteToOwner({
organisationId,
userId: row.original.userId,
})
}
organisationId={organisationId}
organisationMember={row.original}
isOwner={isOwner}
/>
>
<Trans>Promote to owner</Trans>
</Button>
</div>
);
},
),
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]);
@ -142,7 +157,8 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
404: {
heading: msg`Organisation not found`,
subHeading: msg`404 Organisation not found`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={

View File

@ -59,7 +59,8 @@ export default function UserPage({ params }: { params: { id: number } }) {
404: {
heading: msg`User not found`,
subHeading: msg`404 User not found`,
message: msg`The user you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The user you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={

View File

@ -117,7 +117,8 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
404: {
heading: msg`Email domain not found`,
subHeading: msg`404 Email domain not found`,
message: msg`The email domain you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The email domain you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={

View File

@ -89,7 +89,8 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
404: {
heading: msg`Organisation group not found`,
subHeading: msg`404 Organisation group not found`,
message: msg`The organisation group you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The organisation group you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={

View File

@ -60,7 +60,8 @@ export default function Layout() {
404: {
heading: msg`Team not found`,
subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The team you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={

View File

@ -71,7 +71,8 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The document you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The document you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
@ -126,11 +127,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
position="bottom"
>
<span>
<Plural
value={envelope.recipients.length}
one="# Recipient"
other="# Recipients"
/>
<Trans>{envelope.recipients.length} Recipient(s)</Trans>
</span>
</StackAvatarsWithTooltip>
</div>
@ -150,13 +147,8 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
overrideSettings={{
showRecipientSigningStatus: true,
showRecipientTooltip: true,
}}
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
@ -164,10 +156,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</CardContent>
</Card>
</EnvelopeRenderProvider>
@ -189,10 +178,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
)}
<PDFViewer
envelopeItem={envelope.envelopeItems[0]}
token={undefined}
document={envelope}
key={envelope.envelopeItems[0].id}
version="signed"
documentData={envelope.envelopeItems[0].documentData}
/>
</CardContent>
</Card>

View File

@ -82,7 +82,8 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The document you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The document you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
@ -100,9 +101,8 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
<EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
<EnvelopeEditor />
</EnvelopeRenderProvider>

View File

@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { FolderType, OrganisationType } from '@prisma/client';
import { useParams, useSearchParams } from 'react-router';
import { Link } from 'react-router';
@ -19,9 +18,9 @@ import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/av
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
@ -109,8 +108,9 @@ export default function DocumentsPage() {
}
}, [data?.stats]);
// Todo: Envelopes - Change the dropzone wrapper to create to V2 documents after we're ready.
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
<DocumentDropZoneWrapper>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
@ -210,6 +210,6 @@ export default function DocumentsPage() {
/>
)}
</div>
</EnvelopeDropZoneWrapper>
</DocumentDropZoneWrapper>
);
}

View File

@ -109,7 +109,8 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
404: {
heading: msg`Webhook not found`,
subHeading: msg`404 Webhook not found`,
message: msg`The webhook you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The webhook you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={

View File

@ -66,7 +66,8 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The template you are looking for may have been removed, renamed or may have never existed.`,
message: msg`The template you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
@ -169,12 +170,8 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
overrideSettings={{
showRecipientTooltip: true,
}}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
@ -182,10 +179,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</CardContent>
</Card>
</EnvelopeRenderProvider>
@ -206,10 +200,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
/>
<PDFViewer
envelopeItem={envelope.envelopeItems[0]}
token={undefined}
version="signed"
document={envelope}
key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData}
/>
</CardContent>
</Card>

View File

@ -1,5 +1,4 @@
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { useParams, useSearchParams } from 'react-router';
@ -9,8 +8,8 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@ -38,7 +37,7 @@ export default function TemplatesPage() {
});
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<TemplateDropZoneWrapper>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
@ -86,6 +85,6 @@ export default function TemplatesPage() {
</div>
</div>
</div>
</EnvelopeDropZoneWrapper>
</TemplateDropZoneWrapper>
);
}

View File

@ -8,6 +8,7 @@ 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';
@ -97,12 +98,15 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
envelopeForSigning,
} as const;
})
.catch((e) => {
.catch(async (e) => {
const error = AppError.parseError(e);
if (error.code === AppErrorCode.UNAUTHORIZED) {
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
return {
isDocumentAccessValid: false,
...requiredAccessData,
} as const;
}
@ -184,7 +188,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
<DocumentSigningAuthProvider
documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient}
isDirectTemplate={true}
user={user}
>
<>
@ -223,21 +226,20 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
}
const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
const isEmailForced = derivedRecipientAccessAuth.includes(DocumentAccessAuth.ACCOUNT);
return (
<EnvelopeSigningProvider
envelopeData={data.envelopeForSigning}
email={isEmailForced ? user?.email || '' : ''} // Doing this allows us to let users change the email if they want to for non-auth templates.
email={''} // Doing this allows us to let users change the email if they want to.
fullName={user?.name}
signature={user?.signature}
>
@ -246,7 +248,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
<EnvelopeRenderProvider envelope={envelope}>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>

View File

@ -492,7 +492,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
<EnvelopeRenderProvider envelope={envelope}>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>

View File

@ -1,9 +1,10 @@
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, DownloadIcon } from 'lucide-react';
import { CheckCircle2, Clock8, FileSearch } from 'lucide-react';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
@ -19,13 +20,14 @@ 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';
@ -205,16 +207,24 @@ 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) && (
<EnvelopeDownloadDialog
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeItems={document.envelopeItems}
token={recipient?.token}
{isDocumentCompleted(document.status) ? (
<DocumentDownloadButton
className="flex-1"
fileName={document.title}
documentData={document.documentData}
disabled={!isDocumentCompleted(document.status)}
/>
) : (
<DocumentDialog
documentData={document.documentData}
trigger={
<Button type="button" variant="outline" className="flex-1">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
<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>
}
/>

View File

@ -60,7 +60,6 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
return {
document,
token: slug,
};
}
@ -75,7 +74,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
};
export default function SharePage() {
const { document, token } = useLoaderData<typeof loader>();
const { document } = useLoaderData<typeof loader>();
if (document) {
return (
@ -87,7 +86,6 @@ export default function SharePage() {
envelopeItems={document.envelopeItems}
recipientCount={document.recipientCount}
completedDate={document.completedAt ?? undefined}
token={token}
/>
);
}

View File

@ -1,5 +1,3 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
@ -11,7 +9,6 @@ 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';
@ -31,12 +28,8 @@ 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(returnTo || '/');
throw redirect('/');
}
return {
@ -44,28 +37,12 @@ export async function loader({ request }: Route.LoaderArgs) {
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
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');
}, []);
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData;
return (
<div className="w-screen max-w-lg px-4">
@ -84,17 +61,13 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
returnTo={returnTo}
/>
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
{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={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
className="text-documenso-700 duration-200 hover:opacity-70"
>
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
Sign up
</Link>
</Trans>

View File

@ -6,7 +6,6 @@ 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';
@ -17,7 +16,7 @@ export function meta() {
return appMetaTags('Sign Up');
}
export function loader({ request }: Route.LoaderArgs) {
export function loader() {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
// SSR env variables.
@ -29,20 +28,15 @@ export function loader({ request }: Route.LoaderArgs) {
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, returnTo } = loaderData;
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
return (
<SignUpForm
@ -50,7 +44,6 @@ export default function SignUp({ loaderData }: Route.ComponentProps) {
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
returnTo={returnTo}
/>
);
}

View File

@ -2,14 +2,11 @@ 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';
@ -32,13 +29,11 @@ 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,
};
@ -49,19 +44,15 @@ export default function Layout() {
}
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData || {};
const { isGoogleSSOEnabled, 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}
@ -77,16 +68,6 @@ 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>;

View File

@ -1,332 +0,0 @@
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>
);
};

View File

@ -0,0 +1,138 @@
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>
);
}

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