Compare commits

..

28 Commits

Author SHA1 Message Date
Lucas Smith f3ec8ddc57 v2.6.1 2026-02-18 21:57:10 +11:00
Lucas Smith 9a66d0ebf6 fix: simplify openapi field schemas to fix SDK generation (#2503) 2026-02-18 17:07:46 +11:00
Konrad 29622d3151 fix(i18n): mark strings inside div for translation (#2514) 2026-02-18 13:50:42 +11:00
Lucas Smith 5de2527e54 fix: v2 embed direct templates not reading email/lockEmail from hash params (#2509) 2026-02-18 13:35:04 +11:00
Lucas Smith 6fcf0a638c chore: add translations (#2507) 2026-02-17 11:31:37 +11:00
Louis Liu ff9e6acb7a fix(ui): clarify email settings labels (#2448) 2026-02-16 17:00:24 +11:00
Lucas Smith a60c6a90ab chore: add translations (#2504) 2026-02-16 16:10:43 +11:00
github-actions[bot] f35c19d098 chore: extract translations (#2458) 2026-02-16 14:34:33 +11:00
McMek590 cf8e21bf35 fix: create full sentences for document-signing-auth files (#2451) 2026-02-16 13:30:36 +11:00
Jahangir Babar 3f7c4df1b1 fix: strip diacritics from team URL slug generation (#2489) 2026-02-16 12:36:14 +11:00
Konrad ca199e7885 fix(i18n): mark span strings for translation (#2494) 2026-02-16 12:07:53 +11:00
Konrad 435d61ea57 fix(i18n): mark badge string for translation (#2495) 2026-02-16 11:58:03 +11:00
Konrad 34f14ba69a fix(i18n): mark tabs trigger strings for translation (#2496) 2026-02-16 11:57:44 +11:00
Konrad 51916cd3f0 fix(i18n): mark DialogTitle string for translation (#2497) 2026-02-16 11:57:23 +11:00
Konrad f158305499 fix(i18n): mark paragraph strings for translation (#2498) 2026-02-16 11:57:03 +11:00
Lucas Smith 2e3d22c856 fix: use instance-specific emails for service accounts (#2502) 2026-02-16 11:52:19 +11:00
Ephraim Duncan d66c330d46 fix: match cert and audit log page dimensions to source document (#2473) 2026-02-12 18:25:11 +11:00
David Nguyen 9bcb240895 fix: revert canceled individual subscriptions to free claim (#2483)
## Description

Resolves an issue where individual plan customers who cancel are not
correctly put down to the free plan.

To resolve this, we delete the subscription on the stripe subscription
delete webhook. Since the customerId is stored on the organisation they
can still access their old invoices.
2026-02-12 17:44:33 +11:00
David Nguyen 066e6bc847 fix: envelope editor flush race condition (#2482)
## Description

Fixes a race condition in the envelope editor when opening "Send
Document" immediately after moving/resizing a selected field

Replication
1. Move or resize a field (do not blur the selector/quickbar that
appears when a field is selected)
2. Directly click the "Send Document" dialog
3. Error appears

Note: Step 2 needs to happen relatively fast after step 1 since this is
a race against the flush debouncer

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-12 16:32:26 +11:00
David Nguyen 0d65693d55 fix: highlight rejected certificate text (#2478)
## Description

- Update the rejected certificate so that is it more clear on who
rejected the document.
- Updated the audit log generation so that the completed audit log is
included

### Before

<img width="681" height="597" alt="image"
src="https://github.com/user-attachments/assets/3dab41c1-c86f-4555-8d50-3d9245be65d5"
/>

### After

Note that the order of the recipient is different in this case

<img width="818" height="769" alt="image"
src="https://github.com/user-attachments/assets/71f0ac12-5859-47b4-8980-2420ef949d18"
/>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2026-02-12 16:06:43 +11:00
Lucas Smith e3dee5e565 fix: auto placement field meta (#2480) 2026-02-12 14:20:52 +11:00
Catalin Pit f1c91c4951 fix: bulk actions improvements (#2440) 2026-02-10 20:13:03 +11:00
Lucas Smith a5ef1d23e6 feat: add team memberships section to admin user detail page (#2457) 2026-02-09 17:35:22 +11:00
github-actions[bot] d91414697d chore: extract translations (#2429) 2026-02-09 17:30:46 +11:00
Konrad e222a872d2 fix(i18n): rewrite audit log messages to support correct grammar (#2455) 2026-02-09 13:20:12 +11:00
Ephraim Duncan e3b0087be6 feat: create plain customer (#2442)
Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2026-02-09 11:24:45 +11:00
Konrad da89ce7c9a fix(i18n): add localization context to dialog messages (#2452) 2026-02-09 10:52:50 +11:00
Konrad b762561f11 chore(i18n): add context to ambiguous message (#2454) 2026-02-09 10:52:00 +11:00
207 changed files with 13318 additions and 11925 deletions
@@ -0,0 +1,168 @@
---
date: 2026-02-11
title: Cert Page Width Mismatch
---
## Problem
Certificate and audit log pages are generated with hardcoded A4 dimensions (`PDF_SIZE_A4_72PPI`: 595×842) regardless of the actual document page sizes. When the source document uses a different page size (e.g., Letter, Legal, or custom dimensions), the certificate/audit log pages end up with a different width than the document pages. This causes problems with courts that expect uniform page dimensions throughout a PDF.
**Both width and height must match** the last page of the document so the entire PDF prints uniformly.
**Root cause**: In `seal-document.handler.ts` (lines 186-187), the certificate payload always uses:
```ts
pageWidth: PDF_SIZE_A4_72PPI.width, // 595
pageHeight: PDF_SIZE_A4_72PPI.height, // 842
```
These hardcoded values flow into `generateCertificatePdf`, `generateAuditLogPdf`, `renderCertificate`, and `renderAuditLogs` — all of which use `pageWidth`/`pageHeight` to set Konva stage dimensions and layout content.
## Key Files
| File | Role |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `packages/lib/jobs/definitions/internal/seal-document.handler.ts` | Orchestrates sealing; passes page dimensions to cert/audit generators |
| `packages/lib/constants/pdf.ts` | Defines `PDF_SIZE_A4_72PPI` (595×842) |
| `packages/lib/server-only/pdf/generate-certificate-pdf.ts` | Generates certificate PDF; accepts `pageWidth`/`pageHeight` |
| `packages/lib/server-only/pdf/generate-audit-log-pdf.ts` | Generates audit log PDF; accepts `pageWidth`/`pageHeight` |
| `packages/lib/server-only/pdf/render-certificate.ts` | Renders certificate pages via Konva; uses `pageWidth`/`pageHeight` for stage + layout |
| `packages/lib/server-only/pdf/render-audit-logs.ts` | Renders audit log pages via Konva; uses `pageWidth`/`pageHeight` for stage + layout |
| `packages/lib/server-only/pdf/get-page-size.ts` | Existing utility — extend with `@libpdf/core` version |
| `packages/trpc/server/document-router/download-document-certificate.ts` | Standalone certificate download (also hardcodes A4) |
| `packages/trpc/server/document-router/download-document-audit-logs.ts` | Standalone audit log download (also hardcodes A4) |
## Architecture
### Current Flow
1. **One cert PDF + one audit log PDF** generated per envelope with hardcoded A4 dims
2. Both appended to **every** envelope item (document) via `decorateAndSignPdf``pdfDoc.copyPagesFrom()`
3. The audit log is envelope-level (all recipients, all events across all docs) — one per envelope, not per document
### Multi-Document Envelopes
- V1 envelopes: single document only
- V2 envelopes: support multiple documents (envelope items)
- Each envelope item gets both cert + audit log pages appended to it
- If documents have different page sizes → need size-matched cert/audit for each
### Reading Page Dimensions (`@libpdf/core` only)
Use `@libpdf/core`'s `PDF` class — NOT `@cantoo/pdf-lib`:
```ts
const pdfDoc = await PDF.load(pdfData);
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
const { width, height } = lastPage; // e.g. 612, 792 for Letter
```
Already used this way in `seal-document.handler.ts` lines 403-410 for V2 field insertion.
"Last page" = last page of the original document, before cert/audit pages are appended.
### Content Layout Adaptation
Both renderers already handle variable dimensions gracefully:
- **Width**: `render-certificate.ts:713` / `render-audit-logs.ts:588``Math.min(pageWidth - minimumMargin * 2, contentMaxWidth)` with `contentMaxWidth = 768`. Wider pages get more margin, narrower pages tighter margins.
- **Height**: Both renderers paginate content into pages using `groupRowsIntoPages()` which respects `pageHeight` via `maxTableHeight = pageHeight - pageTopMargin - pageBottomMargin`. Shorter pages just mean more pages; taller pages fit more rows per page.
### Playwright PDF Path — Out of Scope
The `NEXT_PRIVATE_USE_PLAYWRIGHT_PDF` toggle enables a deprecated Playwright-based PDF generation path (`get-certificate-pdf.ts`, `get-audit-logs-pdf.ts`) that also hardcodes `format: 'A4'` in `page.pdf()`. This path is **not being updated** as part of this fix:
- Both files are marked `@deprecated`
- The Konva-based path is the default and recommended path
- The Playwright path is behind a feature flag and will be removed
No changes needed. Add a code comment noting the A4 limitation if the Playwright path is ever re-enabled.
## Plan
### 1. Extend `get-page-size.ts` with `@libpdf/core` utility
Add a `getLastPageDimensions` function to the existing `packages/lib/server-only/pdf/get-page-size.ts` file. This consolidates page-size logic in one place (the file already has the legacy `@cantoo/pdf-lib` version).
```ts
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
const width = Math.round(lastPage.width);
const height = Math.round(lastPage.height);
if (width < MIN_CERT_PAGE_WIDTH || height < MIN_CERT_PAGE_HEIGHT) {
return { width: PDF_SIZE_A4_72PPI.width, height: PDF_SIZE_A4_72PPI.height };
}
return { width, height };
};
```
**Dimension rounding**: `Math.round()` both width and height. PDF points at 72ppi are typically whole numbers; rounding avoids spurious float-precision mismatches (e.g., 612.0 vs 612.00001) that would cause unnecessary duplicate cert/audit PDF generation.
**Minimum page dimensions**: Enforce a minimum threshold (e.g., 300pt for both width and height). If either dimension falls below the minimum, fall back to A4 (595×842). The certificate and audit log renderers have headers, table rows, margins, and QR codes that require a minimum viable area.
### 2. Read last page dimensions from each envelope item's PDF
In `seal-document.handler.ts`, before generating cert/audit PDFs:
- For each `envelopeItem`, load the PDF and read the **last page's width and height** using `getLastPageDimensions`
- Use `PDF.load()` then pass the loaded doc to the utility
**Resealing consideration**: When `isResealing` is true, envelope items are remapped to use `initialData` (lines 152-158) before this point. Page-size extraction must operate on the same data source that `decorateAndSignPdf` will use. Since the `envelopeItems` array is already remapped by the time we read dimensions, reading from `envelopeItem.documentData` will naturally give the correct (initial) data. No special handling needed beyond ensuring the dimension read happens **after** the resealing remap.
### 3. Generate cert/audit PDFs per unique page size
Current flow generates one cert + one audit log doc per envelope. Change to:
1. Collect `{ width, height }` of the last page for each envelope item
2. Deduplicate by `"${width}x${height}"` key (using the already-rounded integers)
3. For each unique size, generate cert PDF and audit log PDF with those dimensions
4. Store in a `Map<string, { certificateDoc, auditLogDoc }>` keyed by `"${width}x${height}"`
For the common single-document case, this is one generation — same perf as today.
### 4. Thread the correct docs into `decorateAndSignPdf`
In the envelope item loop, look up the item's last-page dimensions in the map and pass the matching cert/audit docs. Signature of `decorateAndSignPdf` doesn't change — it still receives a single `certificateDoc` and `auditLogDoc`, just the right ones per item.
### 5. Update standalone download routes
`download-document-certificate.ts` and `download-document-audit-logs.ts` also hardcode A4:
- Both routes have `documentId` which maps to a specific envelope item
- Fetch **that specific document's** PDF data, load it, read last page width + height via `getLastPageDimensions`
- Pass `{ pageWidth, pageHeight }` to the generator
- This ensures the standalone download matches the dimensions the user would see in the sealed PDF for that document
### 6. Edge cases
| Scenario | Behavior |
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
| Mixed page sizes within one PDF | Use last page's dimensions (per spec) |
| Page dimensions below minimum threshold | Fall back to A4 (595×842) |
| Landscape pages | width/height just swap roles; renderers adapt via `Math.min()` capping. No special handling |
| Fallback if page dims unreadable | Default to A4 (595×842) |
| Resealing | Dimensions read after `initialData` remap — correct source automatically |
| Playwright PDF path enabled | Remains A4 — out of scope, deprecated |
| Single-doc envelope (most common) | One generation, same perf as today |
| Multi-doc envelope, same page sizes | Dedup key matches → one generation |
| Multi-doc envelope, different sizes | One generation per unique size |
### 7. Tests
- Add assertion-based E2E test (no visual regression / reference images needed)
- Seal a Letter-size (612×792) PDF through the full flow
- Load the sealed output and assert all pages (document + cert + audit) have matching width/height
- Can be added to `envelope-alignment.spec.ts` or as a new focused test
## Implementation Steps
1. **Extend `get-page-size.ts`** — add `getLastPageDimensions(pdfDoc: PDF): { width: number; height: number }` using `@libpdf/core`, with `Math.round()` and minimum dimension enforcement
2. **In `seal-document.handler.ts`**:
a. After the resealing remap (line ~159), load each envelope item's PDF via `PDF.load()` and collect last page `{ width, height }` using `getLastPageDimensions`
b. Deduplicate by `"${width}x${height}"` key
c. Generate cert/audit PDFs per unique size (parallel via `Promise.all`)
d. In envelope item loop, look up matching cert/audit doc by size key
3. **Fix `download-document-certificate.ts`** — load the specific document's PDF, read last page dims via `getLastPageDimensions`, pass to generator
4. **Fix `download-document-audit-logs.ts`** — same as above, using the specific `documentId`'s PDF
5. **Add E2E test** — assertion-based test with a Letter-size document verifying all page dimensions match after sealing
@@ -116,9 +116,11 @@ export function AssistantConfirmationDialog({
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
<Trans>
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</Trans>
</p>
<Button
@@ -1,5 +1,3 @@
import { useRef } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
@@ -7,7 +5,6 @@ import { useNavigate } from 'react-router';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -16,6 +13,7 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
@@ -40,8 +38,6 @@ export const DocumentDuplicateDialog = ({
const team = useCurrentTeam();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpcReact.envelope.item.getManyByToken.useQuery(
{
@@ -99,13 +95,12 @@ export const DocumentDuplicateDialog = ({
</h1>
</div>
) : (
<div ref={scrollContainerRef} className="h-[50vh] overflow-y-scroll p-2">
<PDFViewer
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={undefined}
version="initial"
scrollParentRef={scrollContainerRef}
version="original"
/>
</div>
)}
@@ -1,215 +0,0 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeDeleteDialogProps = {
id: string;
type: EnvelopeType;
trigger?: React.ReactNode;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
title: string;
canManageDocument: boolean;
};
export const EnvelopeDeleteDialog = ({
id,
type,
trigger,
onDelete,
status,
title,
canManageDocument,
}: EnvelopeDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { t } = useLingui();
const deleteMessage = msg`delete`;
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: t`Document deleted`,
description: t`"${title}" has been successfully deleted`,
duration: 5000,
});
await onDelete?.();
setOpen(false);
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`This document could not be deleted at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === t(deleteMessage));
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{title}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{title}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
{canManageDocument ? (
<Alert variant="warning" className="-mt-1">
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
{type === EnvelopeType.DOCUMENT ? (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
) : (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this template will be permanently deleted.
</Trans>
)}
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>Document will be permanently deleted</Trans>
</li>
<li>
<Trans>Document signing process will be cancelled</Trans>
</li>
<li>
<Trans>All inserted signatures will be voided</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document will be hidden from your account</Trans>
</li>
<li>
<Trans>Recipients will still retain their copy of the document</Trans>
</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<Trans>Please contact support if you would like to revert this action.</Trans>
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
/>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void deleteEnvelope({ envelopeId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -13,7 +13,6 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
@@ -117,15 +116,10 @@ export const EnvelopeDistributeDialog = ({
} = form;
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
const emails = emailData?.data || [];
@@ -264,10 +258,10 @@ export const EnvelopeDistributeDialog = ({
>
<TabsList className="w-full">
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
Email
<Trans>Email</Trans>
</TabsTrigger>
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
None
<Trans>None</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -149,7 +149,12 @@ export const EnvelopesBulkDeleteDialog = ({
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" disabled={isPending}>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
<Trans>Cancel</Trans>
</Button>
@@ -77,7 +77,7 @@ export const OrganisationGroupDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
<Trans context="Removing group from organisation">
You are about to remove the following group from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
@@ -127,7 +127,11 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
};
const mapTextToUrl = (text: string) => {
return text.toLowerCase().replace(/\s+/g, '-');
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/\s+/g, '-');
};
const dialogState = useMemo(() => {
@@ -260,7 +264,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
<span className="text-xs font-normal text-foreground/50">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
@@ -288,7 +292,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
/>
<label
className="text-muted-foreground ml-2 text-sm"
className="ml-2 text-sm text-muted-foreground"
htmlFor="inherit-members"
>
<Trans>Allow all organisation members to access this team</Trans>
@@ -81,7 +81,7 @@ export const TeamGroupDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
<Trans context="Removing group from team">
You are about to remove the following group from{' '}
<span className="font-semibold">{team.name}</span>.
</Trans>
@@ -8,8 +8,6 @@ import { useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
import { pdfToImagesClientSide } from '@documenso/lib/server-only/ai/pdf-to-images.client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -54,17 +52,12 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const pdfImages = await pdfToImagesClientSide(uint8Array, {
scale: PDF_IMAGE_RENDER_SCALE,
});
// Store file metadata and UInt8Array in form data
form.setValue('documentData', {
name: file.name,
type: file.type,
size: file.size,
data: uint8Array, // Store as UInt8Array
images: pdfImages,
});
// Auto-populate title if it's empty
@@ -151,7 +144,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
<div
{...getRootProps()}
className={cn(
'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background transition',
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
{
'border-primary/50 bg-primary/5': isDragActive,
'hover:bg-muted/30':
@@ -200,21 +193,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
</FormControl>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
<Loader className="h-10 w-10 animate-spin text-muted-foreground" />
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
</div>
)}
</div>
) : (
<div className="mt-2 rounded-lg border p-4">
<div className="flex items-center gap-x-4">
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
<FileText className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="text-sm font-medium">{documentData.name}</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
{formatFileSize(documentData.size)}
</div>
</div>
@@ -46,13 +46,6 @@ export const ZConfigureEmbedFormSchema = z.object({
type: z.string(),
size: z.number(),
data: z.instanceof(Uint8Array), // UInt8Array can't be directly validated by zod
images: z
.object({
width: z.number(),
height: z.number(),
image: z.string(),
})
.array(),
})
.optional(),
});
@@ -5,6 +5,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, 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';
@@ -15,7 +16,6 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,6 +24,7 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -83,7 +84,7 @@ export const ConfigureFieldsView = ({
};
}, []);
const overrideImages = useMemo(() => {
const normalizedDocumentData = useMemo(() => {
if (envelopeItem) {
return undefined;
}
@@ -92,7 +93,7 @@ export const ConfigureFieldsView = ({
return undefined;
}
return configData.documentData.images;
return base64.encode(configData.documentData.data);
}, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => {
@@ -545,13 +546,12 @@ export const ConfigureFieldsView = ({
<Form {...form}>
<div>
<PDFViewer
<PDFViewerLazy
presignToken={presignToken}
overrideImages={overrideImages}
overrideData={normalizedDocumentData}
envelopeItem={normalizedEnvelopeItem}
token={undefined}
version="current"
scrollParentRef="window"
version="signed"
/>
<ElementVisible
@@ -1,3 +1,4 @@
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
export const EmbedClientLoading = () => {
@@ -5,7 +6,9 @@ export const EmbedClientLoading = () => {
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
<Loader className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
<span>
<Trans>Loading...</Trans>
</span>
</div>
);
};
@@ -31,11 +31,11 @@ import type {
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -341,11 +341,10 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="flex-1">
<PDFViewer
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="current"
scrollParentRef="window"
version="signed"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -500,7 +499,9 @@ export const EmbedDirectTemplateClientPage = ({
{!hidePoweredBy && (
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -19,11 +19,11 @@ import {
DocumentReadOnlyFields,
} from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -287,11 +287,10 @@ export const EmbedSignDocumentV1ClientPage = ({
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<PDFViewer
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={token}
version="current"
scrollParentRef="window"
version="signed"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -511,7 +510,9 @@ export const EmbedSignDocumentV1ClientPage = ({
{!hidePoweredBy && (
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { EnvelopeType } from '@prisma/client';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
@@ -25,7 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const { envelope, recipient, envelopeData, setFullName, fullName } =
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName } =
useRequiredEnvelopeSigningContext();
const { isCompleted, isRejected, recipientSignature } = envelopeData;
@@ -35,6 +36,7 @@ export const EmbedSignDocumentV2ClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT);
const onDocumentCompleted = (data: {
token: string;
@@ -132,6 +134,17 @@ export const EmbedSignDocumentV2ClientPage = ({
// 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);
if (envelope.type === EnvelopeType.TEMPLATE) {
if (!isCompleted && data.email) {
setEmail(data.email);
}
if (data.email) {
setIsEmailLocked(!!data.lockEmail);
}
}
setAllowDocumentRejection(!!data.allowDocumentRejection);
if (data.darkModeDisabled) {
@@ -213,6 +226,7 @@ export const EmbedSignDocumentV2ClientPage = ({
return (
<EmbedSigningProvider
isNameLocked={isNameLocked}
isEmailLocked={isEmailLocked}
hidePoweredBy={hidePoweredBy}
allowDocumentRejection={allowDocumentRejection}
onDocumentCompleted={onDocumentCompleted}
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -17,12 +17,12 @@ import type {
} from '@documenso/trpc/server/field-router/schema';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -66,8 +66,6 @@ export const MultiSignDocumentSigningView = ({
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isExpanded, setIsExpanded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
@@ -181,11 +179,7 @@ export const MultiSignDocumentSigningView = ({
return (
<div className="min-h-screen overflow-hidden bg-background">
<div
id="document-field-portal-root"
ref={scrollContainerRef}
className="relative h-full w-full overflow-y-auto p-8"
>
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
{match({ isLoading, document })
.with({ isLoading: true }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
@@ -232,11 +226,10 @@ export const MultiSignDocumentSigningView = ({
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
})}
>
<PDFViewer
<PDFViewerLazy
envelopeItem={document.envelopeItems[0]}
token={token}
version="current"
scrollParentRef={scrollContainerRef}
version="signed"
onDocumentLoad={() => {
setHasDocumentLoaded(true);
onDocumentReady?.();
@@ -124,7 +124,9 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
</div>
<p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
© {new Date().getFullYear()} Documenso, Inc.
<br />
<Trans>All rights reserved.</Trans>
</p>
</div>
</SheetContent>
@@ -118,7 +118,9 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
{price.product.features && price.product.features.length > 0 && (
<div className="mt-4 text-muted-foreground">
<div className="text-sm font-medium">Includes:</div>
<div className="text-sm font-medium">
<Trans>Includes:</Trans>
</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
@@ -10,10 +10,10 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { trpc } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -151,12 +151,11 @@ export const DirectTemplatePageView = ({
gradient
>
<CardContent className="p-2">
<PDFViewer
<PDFViewerLazy
key={template.id}
envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token}
version="current"
scrollParentRef="window"
version="signed"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
@@ -27,7 +28,6 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuth2FAProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -44,7 +44,6 @@ type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
export const DocumentSigningAuth2FA = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -101,14 +100,39 @@ export const DocumentSigningAuth2FA = ({
<Alert variant="warning">
<AlertDescription>
<p>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
) : (
// Todo: Translate
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
)}
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to sign this field.</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to sign this document.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to approve this field.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to approve this document.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to view this field.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to view this field.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to view this document.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup 2FA to assist with this field.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup 2FA to assist with this document.</Trans>
))
.exhaustive()}
</p>
<p className="mt-2">
<Trans>
By enabling 2FA, you will be required to enter a code from your authenticator app
@@ -138,7 +162,9 @@ export const DocumentSigningAuth2FA = ({
name="token"
render={({ field }) => (
<FormItem>
<FormLabel required>2FA token</FormLabel>
<FormLabel required>
<Trans>2FA token</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { authClient } from '@documenso/auth/client';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -13,13 +14,11 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuthAccountProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
onOpenChange: (value: boolean) => void;
};
export const DocumentSigningAuthAccount = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onOpenChange,
}: DocumentSigningAuthAccountProps) => {
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
@@ -55,32 +54,110 @@ export const DocumentSigningAuthAccount = ({
<fieldset disabled={isSigningOut} className="space-y-4">
<Alert variant="warning">
<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>
)}
</span>
)}
<span>
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To sign this field, you need to be logged in.</Trans>
) : (
<Trans>
To sign this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To sign this document, you need to be logged in.</Trans>
) : (
<Trans>
To sign this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To approve this field, you need to be logged in.</Trans>
) : (
<Trans>
To approve this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To approve this document, you need to be logged in.</Trans>
) : (
<Trans>
To approve this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To view this field, you need to be logged in.</Trans>
) : (
<Trans>
To view this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () =>
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>
),
)
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To view this field, you need to be logged in.</Trans>
) : (
<Trans>
To view this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To view this document, you need to be logged in.</Trans>
) : (
<Trans>
To view this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () =>
isDirectTemplate ? (
<Trans>To assist with this field, you need to be logged in.</Trans>
) : (
<Trans>
To assist with this field, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () =>
isDirectTemplate ? (
<Trans>To assist with this document, you need to be logged in.</Trans>
) : (
<Trans>
To assist with this document, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
),
)
.exhaustive()}
</span>
</AlertDescription>
</Alert>
@@ -8,6 +8,7 @@ import { RecipientRole } from '@prisma/client';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
@@ -38,7 +39,6 @@ import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-p
export type DocumentSigningAuthPasskeyProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -52,7 +52,6 @@ type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
export const DocumentSigningAuthPasskey = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -128,9 +127,62 @@ export const DocumentSigningAuthPasskey = ({
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{/* Todo: Translate */}
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
this {actionTarget.toLowerCase()}.
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to sign this field.
</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to sign this document.
</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to approve this field.
</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to approve this
document.
</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to view this field.
</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to mark this document as
viewed.
</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to view this field.
</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to view this document.
</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>
Your browser does not support passkeys, which is required to assist with this
field.
</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>
Your browser does not support passkeys, which is required to assist with this
document.
</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
@@ -178,10 +230,38 @@ export const DocumentSigningAuthPasskey = ({
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{/* Todo: Translate */}
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
? 'You need to setup a passkey to mark this document as viewed.'
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
{match({ role: recipient.role, actionTarget })
.with({ role: RecipientRole.SIGNER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to sign this field.</Trans>
))
.with({ role: RecipientRole.SIGNER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to sign this document.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to approve this field.</Trans>
))
.with({ role: RecipientRole.APPROVER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to approve this document.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to view this field.</Trans>
))
.with({ role: RecipientRole.VIEWER, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to mark this document as viewed.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to view this field.</Trans>
))
.with({ role: RecipientRole.CC, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to view this document.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'FIELD' }, () => (
<Trans>You need to setup a passkey to assist with this field.</Trans>
))
.with({ role: RecipientRole.ASSISTANT, actionTarget: 'DOCUMENT' }, () => (
<Trans>You need to setup a passkey to assist with this document.</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
@@ -213,7 +293,9 @@ export const DocumentSigningAuthPasskey = ({
name="passkeyId"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey</FormLabel>
<FormLabel required>
<Trans>Passkey</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
@@ -241,20 +323,24 @@ export const DocumentSigningAuthPasskey = ({
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>Unauthorized</AlertTitle>
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please try again or contact support
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
Sign
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</div>
@@ -23,8 +23,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthPasswordProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
@@ -40,8 +38,6 @@ const ZPasswordAuthFormSchema = z.object({
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
export const DocumentSigningAuthPassword = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
@@ -162,7 +162,9 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Automatically sign fields</DialogTitle>
<DialogTitle>
<Trans>Automatically sign fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="text-muted-foreground max-w-[50ch]">
@@ -122,7 +122,9 @@ export const DocumentSigningMobileWidget = () => {
{!hidePoweredBy && (
<div className="mt-2 inline-block rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:hidden">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -27,10 +27,10 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
@@ -274,12 +274,11 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex-1">
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewer
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={recipient.token}
version="current"
scrollParentRef="window"
version="signed"
/>
</CardContent>
</Card>
@@ -1,4 +1,4 @@
import { lazy, useMemo, useRef } from 'react';
import { lazy, useMemo } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
@@ -8,9 +8,8 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -41,8 +40,6 @@ const EnvelopeSignerPageRenderer = lazy(
export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const {
isDirectTemplate,
envelope,
@@ -202,10 +199,7 @@ export const DocumentSigningPageViewV2 = () => {
</div>
</div>
<div
className="embed--DocumentContainer flex-1 overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -234,16 +228,15 @@ export const DocumentSigningPageViewV2 = () => {
{/* Document View */}
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem ? (
<EnvelopePdfViewer
<PDFViewerKonvaLazy
renderer="signing"
key={currentEnvelopeItem.id}
customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<p className="text-sm text-foreground">
<Trans>No document selected</Trans>
<Trans>No documents found</Trans>
</p>
</div>
)}
@@ -259,7 +252,9 @@ export const DocumentSigningPageViewV2 = () => {
target="_blank"
className="fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:block"
>
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</a>
)}
@@ -8,7 +8,6 @@ import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -50,16 +49,9 @@ export const DocumentAttachmentsPopover = ({
const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery(
{
envelopeId,
},
{
// Note: The invalidation of the query is manually handled by the onSuccess
// callbacks below for create and delete mutations.
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
});
const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({
@@ -151,7 +143,7 @@ export const DocumentAttachmentsPopover = ({
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="mt-1 text-sm text-muted-foreground">
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
@@ -161,7 +153,7 @@ export const DocumentAttachmentsPopover = ({
{attachments?.data.map((attachment) => (
<div
key={attachment.id}
className="flex items-center justify-between rounded-md border border-border p-2"
className="border-border flex items-center justify-between rounded-md border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
@@ -169,7 +161,7 @@ export const DocumentAttachmentsPopover = ({
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
>
{attachment.data}
</a>
@@ -1,4 +1,4 @@
import { lazy, useEffect, useRef, useState } from 'react';
import { lazy, useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
@@ -9,11 +9,9 @@ import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -23,6 +21,7 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
@@ -36,7 +35,7 @@ export type DocumentCertificateQRViewProps = {
documentId: number;
title: string;
internalVersion: number;
envelopeItems: (EnvelopeItem & { documentData: Omit<DocumentData, 'metadata'> })[];
envelopeItems: (EnvelopeItem & { documentData: DocumentData })[];
documentTeamUrl: string;
recipientCount?: number;
completedDate?: Date;
@@ -105,13 +104,11 @@ export const DocumentCertificateQRView = ({
{internalVersion === 2 ? (
<EnvelopeRenderProvider
version="current"
envelope={{
id: envelopeItems[0].envelopeId,
envelopeItems,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
envelopeItems={envelopeItems}
token={token}
>
<DocumentCertificateQrV2
@@ -152,12 +149,11 @@ export const DocumentCertificateQRView = ({
</div>
<div className="mt-12 w-full">
<PDFViewer
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={token}
version="current"
scrollParentRef="window"
version="signed"
/>
</div>
</>
@@ -179,9 +175,7 @@ const DocumentCertificateQrV2 = ({
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
const { envelopeItems } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
@@ -213,14 +207,10 @@ const DocumentCertificateQrV2 = ({
/>
</div>
<div className="mt-12 max-h-[80vh] w-full overflow-y-auto" ref={scrollableContainerRef}>
<div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
</div>
</div>
);
@@ -15,7 +15,6 @@ import {
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
@@ -28,6 +27,7 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -440,12 +440,11 @@ export const DocumentEditForm = ({
gradient
>
<CardContent className="p-2">
<PDFViewer
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={undefined}
version="current"
scrollParentRef="window"
version="signed"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -175,6 +175,15 @@ export const EnvelopeEditorFieldDragDrop = ({
const { top, left, height, width } = getBoundingClientRect($page);
console.log({
top,
left,
height,
width,
rawPageX: event.pageX,
rawPageY: event.pageY,
});
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
@@ -269,13 +278,13 @@ export const EnvelopeEditorFieldDragDrop = ({
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
)}
>
<p
className={cn(
'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
field.className,
{
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
@@ -297,7 +306,7 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && (
<div
className={cn(
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
@@ -10,10 +10,7 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import {
MIN_FIELD_HEIGHT_PX,
@@ -25,15 +22,10 @@ import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { CommandDialog } from '@documenso/ui/primitives/command';
import { EnvelopePageImage } from '../envelope/envelope-page-image';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
export default function EnvelopeEditorFieldsPageRenderer({
pageData,
}: {
pageData: PageRenderData;
}) {
export default function EnvelopeEditorFieldsPageRenderer() {
const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -48,24 +40,31 @@ export default function EnvelopeEditorFieldsPageRenderer({
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
renderStatus,
imageProps,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { scale, pageNumber } = pageData;
const { _className, scale } = pageContext;
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageNumber],
[editorFields.localFields, pageContext.pageNumber],
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const { current: container } = canvasElement;
if (!container) {
return;
}
const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group;
@@ -345,6 +344,7 @@ export default function EnvelopeEditorFieldsPageRenderer({
// Create a field if no items are selected or the size is too small.
if (
selectedFieldGroups.length === 0 &&
canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
@@ -434,15 +434,31 @@ export default function EnvelopeEditorFieldsPageRenderer({
renderFieldOnLayer(field);
});
// Reconcile selection state with live field nodes after flush/sync updates.
const liveSelectedFieldGroups = selectedKonvaFieldGroups.filter((fieldGroup) => {
if (!fieldGroup.getStage() || !fieldGroup.getParent()) {
return false;
}
return localPageFields.some((field) => field.formId === fieldGroup.id());
});
if (liveSelectedFieldGroups.length !== selectedKonvaFieldGroups.length) {
setSelectedFields(liveSelectedFieldGroups);
}
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields]);
}, [localPageFields, selectedKonvaFieldGroups]);
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fieldGroups = nodes.filter((node) => node.hasName('field-group')) as Konva.Group[];
const fieldGroups = nodes.filter(
(node) =>
node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
) as Konva.Group[];
interactiveTransformer.current?.nodes(fieldGroups);
setSelectedKonvaFieldGroups(fieldGroups);
@@ -515,7 +531,7 @@ export default function EnvelopeEditorFieldsPageRenderer({
removePendingField();
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
@@ -530,7 +546,7 @@ export default function EnvelopeEditorFieldsPageRenderer({
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
page: pageContext.pageNumber,
type,
positionX: fieldX,
positionY: fieldY,
@@ -559,7 +575,10 @@ export default function EnvelopeEditorFieldsPageRenderer({
}
return (
<div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging && (
@@ -622,7 +641,13 @@ export default function EnvelopeEditorFieldsPageRenderer({
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
<EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);
}
@@ -665,6 +690,10 @@ const FieldActionButtons = ({
selectedFieldFormId.includes(field.formId),
);
if (fields.length === 0) {
return null;
}
const recipient = envelope.recipients.find(
(recipient) => recipient.id === fields[0].recipientId,
);
@@ -680,7 +709,7 @@ const FieldActionButtons = ({
}
return null;
}, [editorFields.localFields]);
}, [editorFields.localFields, envelope.recipients, selectedFieldFormId]);
return (
<div className="flex flex-col items-center" {...props}>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { lazy, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
@@ -6,13 +6,12 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { useRevalidator, useSearchParams } from 'react-router';
import { Link, useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import {
FIELD_META_DEFAULT_VALUES,
@@ -30,7 +29,7 @@ import {
} from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
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 { Separator } from '@documenso/ui/primitives/separator';
@@ -50,10 +49,13 @@ import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import EnvelopeEditorFieldsPageRenderer from './envelope-editor-fields-page-renderer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
);
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@@ -73,9 +75,7 @@ export const EnvelopeEditorFieldsPage = () => {
const team = useCurrentTeam();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -97,10 +97,14 @@ export const EnvelopeEditorFieldsPage = () => {
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
// Todo: Envelopes - Clean up console logs.
if (!isMetaSame) {
console.log('TRIGGER UPDATE');
editorFields.updateFieldByFormId(selectedField.formId, {
fieldMeta,
});
} else {
console.log('DATA IS SAME, NO UPDATE');
}
};
@@ -152,12 +156,12 @@ export const EnvelopeEditorFieldsPage = () => {
return (
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
<div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
<div className="mt-4 flex flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
@@ -172,17 +176,18 @@ export const EnvelopeEditorFieldsPage = () => {
</AlertDescription>
</div>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
<Button asChild variant="outline">
<Link to={`${relativePath.editorPath}`}>
<Trans>Add Recipients</Trans>
</Link>
</Button>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
<PDFViewerKonvaLazy
renderer="editor"
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -244,40 +249,36 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</section>
{/* Field details section. */}
@@ -295,19 +296,31 @@ export const EnvelopeEditorFieldsPage = () => {
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
<p>
<span className="min-w-12 text-muted-foreground">Pos X:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>
&nbsp;
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Pos Y:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>
&nbsp;
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Width:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>
&nbsp;
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Height:&nbsp;</span>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>
&nbsp;
{selectedField.height.toFixed(2)}
</p>
</div>
@@ -30,56 +30,21 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() {
const { t } = useLingui();
const {
envelope,
isDocument,
isTemplate,
isEmbedded,
updateEnvelope,
autosaveError,
relativePath,
editorConfig,
flushAutosave,
} = useCurrentEnvelopeEditor();
const {
embeded,
general: { allowConfigureEnvelopeTitle },
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embeded?.onCreate?.(latestEnvelope);
};
const handleUpdateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embeded?.onUpdate?.(latestEnvelope);
};
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
useCurrentEnvelopeEditor();
return (
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{editorConfig.embeded?.customBrandingLogo ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt="Logo"
className="h-6 w-auto"
/>
) : (
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center space-x-2">
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
disabled={envelope.status !== DocumentStatus.DRAFT}
value={envelope.title}
onChange={(title) => {
updateEnvelope({
@@ -162,71 +127,53 @@ export default function EnvelopeEditorHeader() {
</div>
<div className="flex items-center space-x-2">
{allowAttachments && (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
)}
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
)}
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
{match({ isEmbedded, isDocument, isTemplate, allowDistributing })
.with({ isEmbedded: false, isDocument: true, allowDistributing: true }, () => (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
))
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
{isDocument && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
))
.otherwise(() => null)}
{embeded?.mode === 'create' && (
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
</Button>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
)}
{embeded?.mode === 'edit' && (
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
</Button>
{isTemplate && (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
</Button>
}
/>
)}
</div>
</div>
@@ -1,4 +1,4 @@
import { lazy, useEffect, useMemo, useRef, useState } from 'react';
import { lazy, useEffect, useMemo, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
@@ -11,13 +11,12 @@ import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -34,8 +33,6 @@ export const EnvelopeEditorPreviewPage = () => {
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
@@ -203,9 +200,7 @@ export const EnvelopeEditorPreviewPage = () => {
// Override the parent renderer provider so we can inject custom fields.
return (
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({
@@ -217,12 +212,12 @@ export const EnvelopeEditorPreviewPage = () => {
}}
>
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto" ref={scrollableContainerRef}>
<div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
@@ -233,10 +228,9 @@ export const EnvelopeEditorPreviewPage = () => {
</Alert>
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
<PDFViewerKonvaLazy
renderer="editor"
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -21,7 +21,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
@@ -63,14 +63,8 @@ import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-de
import { useCurrentTeam } from '~/providers/team';
export const EnvelopeEditorRecipientForm = () => {
const {
envelope,
setRecipientsDebounced,
updateEnvelope,
editorRecipients,
isEmbedded,
editorConfig,
} = useCurrentEnvelopeEditor();
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } =
useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
@@ -78,9 +72,7 @@ export const EnvelopeEditorRecipientForm = () => {
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { user } = useSession();
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
@@ -141,7 +133,6 @@ export const EnvelopeEditorRecipientForm = () => {
},
{
enabled: debouncedRecipientSearchQuery.length > 1,
retry: false,
},
);
@@ -612,41 +603,37 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<div className="flex flex-row items-center space-x-2">
{editorConfig.recipients?.allowAIDetection && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
)}
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
{!isEmbedded && (
<Button
variant="outline"
className="flex flex-row items-center"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
>
<Trans>Add Myself</Trans>
</Button>
)}
<Button
variant="outline"
className="flex flex-row items-center"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
>
<Trans>Add Myself</Trans>
</Button>
<Button
variant="outline"
@@ -665,13 +652,7 @@ export const EnvelopeEditorRecipientForm = () => {
<CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
hidden:
!editorConfig.recipients?.allowConfigureSigningOrder &&
!organisation.organisationClaim.flags.cfr21,
})}
>
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
@@ -689,66 +670,64 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
)}
{editorConfig.recipients?.allowConfigureSigningOrder && (
<FormField
control={form.control}
name="signingOrder"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) => {
if (!checked && hasAssistantRole) {
setShowSigningOrderConfirmation(true);
return;
}
<FormField
control={form.control}
name="signingOrder"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) => {
if (!checked && hasAssistantRole) {
setShowSigningOrderConfirmation(true);
return;
}
field.onChange(
checked
? DocumentSigningOrder.SEQUENTIAL
: DocumentSigningOrder.PARALLEL,
);
field.onChange(
checked
? DocumentSigningOrder.SEQUENTIAL
: DocumentSigningOrder.PARALLEL,
);
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
}
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false, {
shouldValidate: true,
shouldDirty: true,
});
}
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>Add 2 or more signers to enable signing order.</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>Add 2 or more signers to enable signing order.</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
{isSigningOrderSequential && (
<FormField
@@ -1008,16 +987,6 @@ export const EnvelopeEditorRecipientForm = () => {
<FormControl>
<RecipientRoleSelect
{...field}
hideAssistantRole={
!editorConfig.recipients?.allowAssistantRole
}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={
!editorConfig.recipients?.allowViewerRole
}
hideApproverRole={
!editorConfig.recipients?.allowApproverRole
}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -1114,15 +1083,13 @@ export const EnvelopeEditorRecipientForm = () => {
onConfirm={handleSigningOrderDisable}
/>
{editorConfig.recipients?.allowAIDetection && (
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
)}
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
@@ -1,28 +0,0 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
export const EnvelopeEditorRenderProviderWrapper = ({
children,
token,
presignedToken,
}: {
children: React.ReactNode;
token?: string;
presignedToken?: string;
}) => {
const { envelope } = useCurrentEnvelopeEditor();
return (
<EnvelopeRenderProvider
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={token}
presignToken={presignedToken}
version="current"
fields={envelope.fields}
recipients={envelope.recipients}
>
{children}
</EnvelopeRenderProvider>
);
};
@@ -28,7 +28,6 @@ import {
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import {
ZDocumentAccessAuthTypesSchema,
@@ -175,9 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({
const { t, i18n } = useLingui();
const { toast } = useToast();
const { envelope, updateEnvelopeAsync, editorConfig, isEmbedded } = useCurrentEnvelopeEditor();
const { settings } = editorConfig;
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -226,15 +223,10 @@ export const EnvelopeEditorSettingsDialog = ({
const emailSettings = form.watch('meta.emailSettings');
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
const emails = emailData?.data || [];
@@ -286,13 +278,11 @@ export const EnvelopeEditorSettingsDialog = ({
setOpen(false);
if (!isEmbedded) {
toast({
title: t`Success`,
description: t`Envelope updated`,
duration: 5000,
});
}
toast({
title: t`Success`,
description: t`Envelope updated`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
@@ -329,7 +319,7 @@ export const EnvelopeEditorSettingsDialog = ({
const selectedTab = tabs.find((tab) => tab.id === activeTab);
if (!selectedTab || !settings) {
if (!selectedTab) {
return null;
}
@@ -350,32 +340,26 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */}
<div className="flex w-80 flex-col border-r bg-accent/20">
<DialogHeader className="p-6 pb-4" data-testid="envelope-editor-settings-dialog-header">
<DialogHeader className="p-6 pb-4">
<DialogTitle>
<Trans>Document Settings</Trans>
</DialogTitle>
</DialogHeader>
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
{tabs.map((tab) => {
if (tab.id === 'email' && !settings.allowConfigureDistribution) {
return null;
}
return (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
);
})}
{tabs.map((tab) => (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
))}
</nav>
</div>
@@ -393,151 +377,137 @@ export const EnvelopeEditorSettingsDialog = ({
disabled={form.formState.isSubmitting}
key={activeTab}
>
{match({ activeTab, settings })
.with({ activeTab: 'general' }, () => (
{match(activeTab)
.with('general', () => (
<>
{settings.allowConfigureLanguage && (
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
that is generated and attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
that is generated and attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
{settings.allowConfigureSignatureTypes && (
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map(
(option) => ({
label: t(option.label),
value: option.value,
}),
)}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
)}
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
{settings.allowConfigureDateFormat && (
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureTimezone && (
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="externalId"
@@ -568,235 +538,227 @@ export const EnvelopeEditorSettingsDialog = ({
)}
/>
{settings.allowConfigureRedirectUrl && (
<FormField
control={form.control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Redirect URL</Trans>{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<FormField
control={form.control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Redirect URL</Trans>{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
Add a URL to redirect the user to once the document is signed
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
</p>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureDistribution && (
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</p>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you can
send to the recipients manually.
</Trans>
</li>
</ul>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you
can send to the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{i18n._(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</>
))
.with('email', () => (
<>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="meta.emailId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{i18n._(description)}
</SelectItem>
),
)}
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
))
.with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => (
<>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="meta.emailId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
),
)
.with({ activeTab: 'security' }, () => (
.with('security', () => (
<>
{organisation.organisationClaim.flags.cfr21 && (
<FormField
@@ -865,7 +827,7 @@ export const EnvelopeEditorSettingsDialog = ({
/>
</>
))
.otherwise(() => null)}
.exhaustive()}
</fieldset>
<div className="flex flex-row justify-end gap-4 p-6">
@@ -9,7 +9,6 @@ export type EnvelopeItemTitleInputProps = {
className?: string;
placeholder?: string;
disabled?: boolean;
dataTestId?: string;
};
export const EnvelopeItemTitleInput = ({
@@ -18,7 +17,6 @@ export const EnvelopeItemTitleInput = ({
className,
placeholder,
disabled,
dataTestId,
}: EnvelopeItemTitleInputProps) => {
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
const [isError, setIsError] = useState(false);
@@ -65,7 +63,6 @@ export const EnvelopeItemTitleInput = ({
{envelopeItemTitle || placeholder}
</span>
<input
data-testid={dataTestId}
data-1p-ignore
autoComplete="off"
ref={inputRef}
@@ -75,7 +72,7 @@ export const EnvelopeItemTitleInput = ({
disabled={disabled}
style={{ width: `${inputWidth}px` }}
className={cn(
'rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
'text-foreground hover:outline-muted-foreground focus:outline-muted-foreground rounded-sm border-0 bg-transparent p-1 text-sm font-medium outline-none hover:outline hover:outline-1 focus:outline focus:outline-1',
className,
{
'outline-red-500': isError,
@@ -8,6 +8,7 @@ import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import {
@@ -16,9 +17,7 @@ import {
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { nanoid } from '@documenso/lib/universal/id';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
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';
@@ -50,14 +49,10 @@ export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const { envelope, setLocalEnvelope, editorFields, editorConfig, isEmbedded, navigateToStep } =
useCurrentEnvelopeEditor();
const { envelopeItems: uploadConfig } = editorConfig;
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems
.sort((a, b) => a.order - b.order)
@@ -108,45 +103,17 @@ export const EnvelopeEditorUploadPage = () => {
);
const onFileDrop = async (files: File[]) => {
const newUploadingFiles: (LocalFile & {
file: File;
data: TEditorEnvelope['envelopeItems'][number]['data'] | null;
})[] = await Promise.all(
files.map(async (file) => {
return {
id: nanoid(),
envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
title: file.name,
file,
isUploading: isEmbedded ? false : true,
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
isError: false,
};
}),
);
const newUploadingFiles: (LocalFile & { file: File })[] = files.map((file) => ({
id: nanoid(),
envelopeItemId: null,
title: file.name,
file,
isUploading: true,
isError: false,
}));
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
// Directly commit the files for embedded documents since those are not uploaded
// until the end of the embedded flow.
if (isEmbedded) {
setLocalEnvelope({
envelopeItems: [
...envelope.envelopeItems,
...newUploadingFiles.map((file) => ({
id: file.envelopeItemId!,
title: file.title,
order: envelope.envelopeItems.length + 1,
envelopeId: envelope.id,
data: file.data!,
})),
],
});
return;
}
const payload = {
envelopeId: envelope.id,
} satisfies TCreateEnvelopeItemsPayload;
@@ -196,9 +163,7 @@ export const EnvelopeEditorUploadPage = () => {
* Hide the envelope item from the list on deletion.
*/
const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) =>
prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId),
);
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId,
@@ -230,30 +195,6 @@ export const EnvelopeEditorUploadPage = () => {
};
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
if (isEmbedded) {
const nextEnvelopeItems = files
.filter((item) => item.envelopeItemId)
.map((item, index) => {
const originalEnvelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === item.envelopeItemId,
);
return {
id: item.envelopeItemId || '',
title: item.title,
order: index + 1,
envelopeId: envelope.id,
data: originalEnvelopeItem?.data,
};
});
setLocalEnvelope({
envelopeItems: nextEnvelopeItems,
});
return;
}
void updateEnvelopeItems({
envelopeId: envelope.id,
data: files
@@ -336,45 +277,32 @@ export const EnvelopeEditorUploadPage = () => {
</CardHeader>
<CardContent>
{uploadConfig?.allowUpload && (
<DocumentDropzone
data-testid="envelope-item-dropzone"
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
)}
<DocumentDropzone
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
{/* Uploaded Files List */}
<div className="mt-4">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div
data-testid="envelope-items-list"
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-2"
>
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
{localFiles.map((localFile, index) => (
<Draggable
key={localFile.id}
isDragDisabled={
isCreatingEnvelopeItems ||
!canItemsBeModified ||
!uploadConfig?.allowConfigureOrder
}
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
draggableId={localFile.id}
index={index}
>
{(provided, snapshot) => (
<div
data-testid={`envelope-item-row-${localFile.id}`}
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
@@ -383,25 +311,18 @@ export const EnvelopeEditorUploadPage = () => {
}`}
>
<div className="flex items-center space-x-3">
{uploadConfig?.allowConfigureOrder && (
<div
{...provided.dragHandleProps}
data-testid={`envelope-item-drag-handle-${localFile.id}`}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
)}
<div
{...provided.dragHandleProps}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
<div>
{localFile.envelopeItemId !== null ? (
<EnvelopeItemTitleInput
disabled={
envelope.status !== DocumentStatus.DRAFT ||
!uploadConfig?.allowConfigureTitle
}
disabled={envelope.status !== DocumentStatus.DRAFT}
value={localFile.title}
dataTestId={`envelope-item-title-input-${localFile.id}`}
placeholder={t`Document Title`}
onChange={(title) => {
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
@@ -434,36 +355,20 @@ export const EnvelopeEditorUploadPage = () => {
</div>
)}
{!localFile.isUploading &&
localFile.envelopeItemId &&
uploadConfig?.allowDelete &&
(isEmbedded ? (
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
onClick={() => onFileDelete(localFile.envelopeItemId!)}
>
<X className="h-4 w-4" />
</Button>
) : (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
>
<X className="h-4 w-4" />
</Button>
}
/>
))}
{!localFile.isUploading && localFile.envelopeItemId && (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button variant="ghost" size="sm">
<X className="h-4 w-4" />
</Button>
}
/>
)}
</div>
</div>
)}
@@ -480,13 +385,14 @@ export const EnvelopeEditorUploadPage = () => {
{/* Recipients Section */}
<EnvelopeEditorRecipientForm />
{editorConfig.general.allowAddFieldsStep && (
<div className="flex justify-end">
<Button type="button" onClick={() => void navigateToStep('addFields')}>
<div className="flex justify-end">
<Button asChild>
<Link to={`${relativePath.editorPath}?step=addFields`}>
<Trans>Add Fields</Trans>
</Button>
</div>
)}
</Link>
</Button>
</div>
</div>
);
};
@@ -1,9 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { motion } from 'framer-motion';
import {
ArrowLeftIcon,
@@ -11,31 +9,32 @@ import {
DownloadCloudIcon,
EyeIcon,
LinkIcon,
type LucideIcon,
MousePointerIcon,
MousePointer,
SendIcon,
SettingsIcon,
Trash2Icon,
UploadIcon,
Upload,
} from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
@@ -44,108 +43,92 @@ import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStepData = {
id: string;
title: MessageDescriptor;
icon: LucideIcon;
description: MessageDescriptor;
};
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
const UPLOAD_STEP = {
id: 'upload',
title: msg`Document & Recipients`,
icon: UploadIcon,
description: msg`Upload documents and add recipients`,
};
const envelopeEditorSteps = [
{
id: 'upload',
order: 1,
title: msg`Document & Recipients`,
icon: Upload,
description: msg`Upload documents and add recipients`,
},
{
id: 'addFields',
order: 2,
title: msg`Add Fields`,
icon: MousePointer,
description: msg`Place and configure form fields in the document`,
},
{
id: 'preview',
order: 3,
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
},
];
const ADD_FIELDS_STEP = {
id: 'addFields',
title: msg`Add Fields`,
icon: MousePointerIcon,
description: msg`Place and configure form fields in the document`,
};
const PREVIEW_STEP = {
id: 'preview',
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
};
export const EnvelopeEditor = () => {
export default function EnvelopeEditor() {
const { t } = useLingui();
const navigate = useNavigate();
const {
envelope,
editorConfig,
isDocument,
isTemplate,
isAutosaving,
flushAutosave,
relativePath,
syncEnvelope,
navigateToStep,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isStepLoading, setIsStepLoading] = useState(false);
const {
general: {
minimizeLeftSidebar,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
},
actions: {
allowDistributing,
allowDirectLink,
allowDuplication,
allowDownloadPDF,
allowDeletion,
allowReturnToPreviousPage,
},
} = editorConfig;
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
const envelopeEditorSteps = useMemo(() => {
const steps: EnvelopeEditorStepData[] = [];
if (allowUploadAndRecipientStep) {
steps.push(UPLOAD_STEP);
// Empty URL param equals upload, otherwise use the step URL param
if (!searchParamStep) {
return 'upload';
}
if (allowAddFieldsStep) {
steps.push(ADD_FIELDS_STEP);
const validSteps: EnvelopeEditorStep[] = ['upload', 'addFields', 'preview'];
if (validSteps.includes(searchParamStep)) {
return searchParamStep;
}
if (allowPreviewStep) {
steps.push(PREVIEW_STEP);
return 'upload';
});
const navigateToStep = (step: EnvelopeEditorStep) => {
setCurrentStep(step);
void flushAutosave();
if (!isStepLoading && isAutosaving) {
setIsStepLoading(true);
}
return steps.map((step, index) => ({
...step,
order: index + 1,
}));
}, [editorConfig]);
const [currentStep, setCurrentStep] = useState<{ step: EnvelopeEditorStep; isLoading: boolean }>(
() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
// Empty URL param equals upload, otherwise use the step URL param
if (!searchParamStep) {
return { step: 'upload', isLoading: false };
}
const validSteps: EnvelopeEditorStep[] = ['upload', 'addFields', 'preview'];
if (validSteps.includes(searchParamStep)) {
return { step: searchParamStep, isLoading: false };
}
return { step: 'upload', isLoading: false };
},
);
// Update URL params: empty for upload, otherwise set the step
if (step === 'upload') {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('step');
return newParams;
});
} else {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.set('step', step);
return newParams;
});
}
};
// Watch the URL params and setStep if the step changes.
useEffect(() => {
@@ -153,19 +136,20 @@ export const EnvelopeEditor = () => {
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep.step) {
if (foundStep && foundStep.id !== currentStep) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
void navigateToStep(foundStep.id as EnvelopeEditorStep).then(() => {
setCurrentStep({
step: foundStep.id as EnvelopeEditorStep,
isLoading: false,
});
});
navigateToStep(foundStep.id as EnvelopeEditorStep);
}
}, [searchParams]);
useEffect(() => {
if (!isAutosaving) {
setIsStepLoading(false);
}
}, [isAutosaving]);
const currentStepData =
envelopeEditorSteps.find((step) => step.id === currentStep.step) || envelopeEditorSteps[0];
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
return (
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
@@ -174,124 +158,57 @@ export const EnvelopeEditor = () => {
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div
className={cn(
'flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4',
{
'w-14': minimizeLeftSidebar,
},
)}
>
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
{/* Left section step selector. */}
{minimizeLeftSidebar ? (
<div className="flex justify-center px-4">
<div className="relative flex h-10 w-10 items-center justify-center">
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
{/* Track circle */}
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-muted"
/>
{/* Progress arc */}
<motion.circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="text-primary"
strokeDasharray={2 * Math.PI * 16}
initial={false}
animate={{
strokeDashoffset:
2 *
Math.PI *
16 *
(1 - (currentStepData.order ?? 0) / envelopeEditorSteps.length),
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-foreground">
<Trans context="The step counter">
{currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</div>
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
</div>
) : (
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
<div className="space-y-3">
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = currentStep === step.id;
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
</div>
</div>
)}
<div
className={cn('space-y-3', {
'px-4': !minimizeLeftSidebar,
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
})}
>
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = currentStep.step === step.id;
return (
<button
key={step.id}
data-testid={`envelope-editor-step-${step.id}`}
type="button"
className={cn(
`cursor-pointer rounded-lg text-left transition-colors ${
return (
<div
key={step.id}
className={`cursor-pointer rounded-lg p-3 transition-colors ${
isActive
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`,
{
'p-3': !minimizeLeftSidebar,
},
)}
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
{!minimizeLeftSidebar && (
}`}
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
<div>
<div
className={`text-sm font-medium ${
@@ -304,101 +221,59 @@ export const EnvelopeEditor = () => {
</div>
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
</div>
)}
</div>
</div>
</button>
);
})}
);
})}
</div>
</div>
<Separator
className={cn('my-6', {
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
})}
/>
<Separator className="my-6" />
{/* Quick Actions. */}
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
'px-2': minimizeLeftSidebar,
})}
>
{!minimizeLeftSidebar && (
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
)}
<div className="space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SettingsIcon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</Button>
}
/>
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
{isDocument && (
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Settings`)}
>
<SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Document Settings</Trans>
) : (
<Trans>Template Settings</Trans>
)}
</span>
)}
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
)}
{isDocument && allowDistributing && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Send Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
)}
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Resend Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
)}
</Button>
}
/>
</>
{isDocument && (
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
)}
{isTemplate && allowDirectLink && (
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
<FileText className="mr-2 h-4 w-4" />
Save as Template
</Button> */}
{isTemplate && (
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
@@ -406,173 +281,100 @@ export const EnvelopeEditor = () => {
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Direct Link`)}
>
<LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
)}
<Button variant="ghost" size="sm" className="w-full justify-start">
<LinkIcon className="mr-2 h-4 w-4" />
<Trans>Direct Link</Trans>
</Button>
}
/>
)}
{allowDuplication && (
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Duplicate Envelope`)}
>
<CopyPlusIcon className="h-4 w-4" />
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<CopyPlusIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Duplicate Document</Trans>
) : (
<Trans>Duplicate Template</Trans>
)}
</Button>
}
/>
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Duplicate Document</Trans>
) : (
<Trans>Duplicate Template</Trans>
)}
</span>
)}
</Button>
}
/>
)}
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{allowDownloadPDF && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Download PDF`)}
>
<DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
)}
</Button>
}
/>
)}
{/* Check envelope ID since it can be in embedded create mode. */}
{allowDeletion && envelope.id && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Delete Envelope`)}
>
<Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Delete Document</Trans>
) : (
<Trans>Delete Template</Trans>
)}
</span>
)}
</Button>
}
onDelete={async () => {
// Todo: Embed - Where to navigate?
await navigate(
envelope.type === EnvelopeType.DOCUMENT
? relativePath.documentRootPath
: relativePath.templateRootPath,
);
}}
/>
)}
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2Icon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</Button>
</div>
{/* Footer of left sidebar. */}
{allowReturnToPreviousPage && (
<div
className={cn('mt-auto px-4', {
'px-2': minimizeLeftSidebar,
})}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'flex items-center justify-center': minimizeLeftSidebar,
})}
asChild
>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</span>
)}
</Link>
</Button>
</div>
{isDocument ? (
<DocumentDeleteDialog
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
status={envelope.status}
documentTitle={envelope.title}
canManageDocument={true}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.documentRootPath);
}}
/>
) : (
<TemplateDeleteDialog
id={mapSecondaryIdToTemplateId(envelope.secondaryId)}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.templateRootPath);
}}
/>
)}
{/* Footer of left sidebar. */}
<div className="mt-auto px-4">
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</Link>
</Button>
</div>
</div>
{/* Main Content - Changes based on current step */}
<AnimateGenericFadeInOut
className="flex-1 overflow-y-auto"
key={currentStep.isLoading ? `loading-${currentStep.step}` : currentStep.step}
>
{match({
isStepLoading: currentStep.isLoading,
currentStep: currentStep.step,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
{match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload', allowUploadAndRecipientStep: true }, () => (
<EnvelopeEditorUploadPage />
))
.with({ currentStep: 'addFields', allowAddFieldsStep: true }, () => (
<EnvelopeEditorFieldsPage />
))
.with({ currentStep: 'preview', allowPreviewStep: true }, () => (
<EnvelopeEditorPreviewPage />
))
.otherwise(() => null)}
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
</AnimateGenericFadeInOut>
</div>
</div>
);
};
}
@@ -5,22 +5,17 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
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';
import { EnvelopePageImage } from '../envelope/envelope-page-image';
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: PageRenderData }) {
export default function EnvelopeGenericPageRenderer() {
const { i18n } = useLingui();
const {
@@ -33,12 +28,19 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
overrideSettings,
} = useCurrentEnvelopeRender();
const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
}, pageData);
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const { scale, pageNumber } = pageData;
const { _className, scale } = pageContext;
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
@@ -47,7 +49,8 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
return fields
.filter(
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
)
.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
@@ -70,7 +73,7 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly,
);
}, [fields, pageNumber, currentEnvelopeItem?.id, recipients]);
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) {
@@ -157,7 +160,10 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
}
return (
<div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{overrideSettings?.showRecipientTooltip &&
localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip
@@ -171,7 +177,13 @@ export default function EnvelopeGenericPageRenderer({ pageData }: { pageData: Pa
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
<EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);
}
@@ -14,10 +14,7 @@ import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
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';
@@ -47,13 +44,12 @@ 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';
import { EnvelopePageImage } from '../envelope/envelope-page-image';
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: PageRenderData }) {
export default function EnvelopeSignerPageRenderer() {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
@@ -81,10 +77,17 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const { stage, pageLayer, imageProps, konvaContainer, unscaledViewport, renderStatus } =
usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData);
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { scale, pageNumber } = pageData;
const { _className, scale } = pageContext;
const { envelope } = envelopeData;
@@ -96,9 +99,10 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
}
return fieldsToRender.filter(
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
);
}, [recipientFields, selectedAssistantRecipientFields, pageNumber]);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
/**
* Returns fields that have been fully signed by other recipients for this specific
@@ -113,7 +117,7 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
return recipient.fields
.filter(
(field) =>
field.page === pageNumber &&
field.page === pageContext.pageNumber &&
field.envelopeItemId === currentEnvelopeItem?.id &&
(field.inserted || field.fieldMeta?.readOnly),
)
@@ -128,7 +132,7 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
},
}));
});
}, [envelope.recipients, pageNumber]);
}, [envelope.recipients, pageContext.pageNumber]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
@@ -530,11 +534,14 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
}
return (
<div className="relative w-full" key={`${currentEnvelopeItem.id}-renderer-${pageNumber}`}>
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
recipientFieldsRemaining[0]?.page === pageNumber && (
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
<EnvelopeFieldToolTip
key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]}
@@ -556,7 +563,13 @@ export default function EnvelopeSignerPageRenderer({ pageData }: { pageData: Pag
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
<EnvelopePageImage renderStatus={renderStatus} imageProps={imageProps} />
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
);
}
@@ -1,32 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Spinner } from '@documenso/ui/primitives/spinner';
type EnvelopePageImageProps = {
renderStatus: 'loading' | 'loaded' | 'error';
imageProps: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown> & { alt: '' };
};
export const EnvelopePageImage = ({ renderStatus, imageProps }: EnvelopePageImageProps) => {
return (
<>
{/* Loading State */}
{renderStatus === 'loading' && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<Spinner />
</div>
)}
{renderStatus === 'error' && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<p>
<Trans>Error loading page</Trans>
</p>
</div>
)}
{/* The PDF image. */}
<img {...imageProps} alt="" />
</>
);
};
@@ -14,11 +14,11 @@ import {
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import type { TTemplate } from '@documenso/lib/types/template';
import { trpc } from '@documenso/trpc/react';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
@@ -312,12 +312,11 @@ export const TemplateEditForm = ({
gradient
>
<CardContent className="p-2">
<PDFViewer
<PDFViewerLazy
key={template.envelopeItems[0].id}
envelopeItem={template.envelopeItems[0]}
token={undefined}
version="current"
scrollParentRef="window"
version="signed"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -51,7 +51,7 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
<div className="mt-8 w-full">
<div className="dark:divide-foreground/30 dark:border-foreground/30 divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200">
<div className="text-muted-foreground dark:bg-foreground/20 bg-neutral-50 p-4 font-medium">
Documents
<Trans>Documents</Trans>
</div>
{Array(rows)
@@ -1,5 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { WebhookTriggerEvents } from '@prisma/client';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
@@ -41,7 +42,11 @@ export const WebhookMultiSelectCombobox = ({
placeholder={_(msg`Select triggers`)}
hideClearAllButton
hidePlaceholderWhenSelected
emptyIndicator={<p className="text-center text-sm">No triggers available</p>}
emptyIndicator={
<p className="text-center text-sm">
<Trans>No triggers available</Trans>
</p>
}
/>
);
};
@@ -96,7 +96,9 @@ export const AdminDocumentLogsTable = ({ envelopeId }: AdminDocumentLogsTablePro
)}
</div>
) : (
<p>N/A</p>
<p>
<Trans>N/A</Trans>
</p>
),
},
{
@@ -0,0 +1,146 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { useLingui as useLinguiMacro } from '@lingui/react/macro';
import { Link, useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import type { TeamMemberRole } from '@documenso/prisma/generated/types';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
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 { HoverCard, HoverCardContent, HoverCardTrigger } from '@documenso/ui/primitives/hover-card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
type AdminUserTeamsTableProps = {
userId: number;
};
export const AdminUserTeamsTable = ({ userId }: AdminUserTeamsTableProps) => {
const { i18n } = useLingui();
const { t } = useLinguiMacro();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.user.findTeams.useQuery({
userId,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: t`Team`,
accessorKey: 'name',
cell: ({ row }) => (
<HoverCard>
<HoverCardTrigger className="cursor-default underline decoration-dotted underline-offset-4">
{row.original.name}
</HoverCardTrigger>
<HoverCardContent
className="w-auto font-mono text-xs text-muted-foreground"
align="start"
>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt>id</dt>
<dd>{row.original.id}</dd>
<dt>url</dt>
<dd>{row.original.url}</dd>
</dl>
</HoverCardContent>
</HoverCard>
),
},
{
header: t`Organisation`,
accessorKey: 'organisation',
cell: ({ row }) => (
<Link
to={`/admin/organisations/${row.original.organisation.id}`}
className="hover:underline"
>
{row.original.organisation.name}
</Link>
),
},
{
header: t`Role`,
accessorKey: 'teamRole',
cell: ({ row }) => (
<Badge variant="neutral">
{i18n._(TEAM_MEMBER_ROLE_MAP[row.original.teamRole as TeamMemberRole])}
</Badge>
),
},
{
header: t`Created At`,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) =>
table.getPageCount() > 1 ? (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
) : null
}
</DataTable>
);
};
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { useSearchParams } from 'react-router';
@@ -88,7 +89,9 @@ export const DocumentLogsTable = ({ documentId, userId }: DocumentLogsTableProps
)}
</div>
) : (
<p>N/A</p>
<p>
<Trans>N/A</Trans>
</p>
),
},
{
@@ -24,7 +24,7 @@ export const EnvelopesTableBulkActionBar = ({
}
return (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-widget px-4 py-3 shadow-lg">
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="text-sm font-medium">
<Trans>{selectedCount} selected</Trans>
</span>
@@ -36,13 +36,7 @@ export const EnvelopesTableBulkActionBar = ({
<Trans>Move to Folder</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDeleteClick}
className="text-destructive hover:text-destructive"
>
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</Button>
@@ -107,7 +107,11 @@ export default function OrganisationGroupSettingsPage({
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.name}</Link>
{row.original.user.id === organisation?.ownerUserId && <Badge>Owner</Badge>}
{row.original.user.id === organisation?.ownerUserId && (
<Badge>
<Trans>Owner</Trans>
</Badge>
)}
</div>
),
},
@@ -208,7 +212,9 @@ export default function OrganisationGroupSettingsPage({
{SUBSCRIPTION_STATUS_MAP[organisation.subscription.status]} subscription found
</span>
) : (
<span>No subscription found</span>
<span>
<Trans>No subscription found</Trans>
</span>
)}
</AlertDescription>
</div>
@@ -10,6 +10,12 @@ import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
@@ -30,6 +36,7 @@ import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-di
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
import { AdminUserTeamsTable } from '~/components/tables/admin-user-teams-table';
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
@@ -197,7 +204,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
<h3 className="text-lg font-semibold leading-none tracking-tight">
<Trans>User Organisations</Trans>
</h3>
<p className="text-muted-foreground mt-1.5 text-sm">
<p className="mt-1.5 text-sm text-muted-foreground">
<Trans>Organisations that the user is a member of.</Trans>
</p>
</div>
@@ -219,6 +226,28 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
/>
</div>
<hr className="my-8" />
<Accordion type="single" collapsible>
<AccordionItem value="team-memberships" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<h3 className="text-lg font-semibold leading-none tracking-tight">
<Trans>Team Memberships</Trans>
</h3>
<p className="mt-1.5 text-sm font-normal text-muted-foreground">
<Trans>Teams that this user is a member of and their roles.</Trans>
</p>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminUserTeamsTable userId={user.id} />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
@@ -9,8 +9,6 @@ import { match } from 'ts-pattern';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
@@ -18,12 +16,12 @@ import {
DocumentReadOnlyFields,
mapFieldsWithRecipients,
} from '@documenso/ui/components/document/document-read-only-fields';
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
@@ -57,14 +55,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery(
{
envelopeId: params.id,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
if (isLoadingEnvelope) {
return (
@@ -161,9 +154,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
{envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
@@ -178,10 +169,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<EnvelopePdfViewer
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef="window"
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</CardContent>
</Card>
@@ -203,12 +193,11 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
/>
)}
<PDFViewer
<PDFViewerLazy
envelopeItem={envelope.envelopeItems[0]}
token={undefined}
key={envelope.envelopeItems[0].id}
version="current"
scrollParentRef="window"
version="signed"
/>
</CardContent>
</Card>
@@ -6,14 +6,13 @@ import { EnvelopeType } from '@prisma/client';
import { Link, useNavigate } from 'react-router';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import EnvelopeEditor from '~/components/general/envelope-editor/envelope-editor';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { useCurrentTeam } from '~/providers/team';
@@ -33,7 +32,6 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
},
{
retry: false,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
@@ -60,7 +58,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
<Spinner />
<Trans>Redirecting</Trans>
</div>
@@ -69,7 +67,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
if (isLoadingEnvelope) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-2 text-foreground">
<div className="text-foreground flex h-screen w-screen flex-col items-center justify-center gap-2">
<Spinner />
<Trans>Loading</Trans>
</div>
@@ -100,9 +98,14 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
return (
<EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeEditorRenderProviderWrapper>
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
>
<EnvelopeEditor />
</EnvelopeEditorRenderProviderWrapper>
</EnvelopeRenderProvider>
</EnvelopeEditorProvider>
);
}
@@ -7,6 +7,7 @@ import { useParams, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { z } from 'zod';
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
@@ -58,7 +59,10 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
'documents-bulk-selection',
{},
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
@@ -121,11 +125,6 @@ export default function DocumentsPage() {
}
}, [data?.stats]);
// Clear selection when navigation or filters change
useEffect(() => {
setRowSelection({});
}, [folderId, findDocumentSearchParams]);
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
@@ -66,7 +66,9 @@ export default function DocumentsFoldersPage() {
onClick={() => navigateToFolder(null)}
>
<HomeIcon className="h-4 w-4" />
<span>Home</span>
<span>
<Trans>Home</Trans>
</span>
</Button>
</div>
@@ -207,7 +207,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
directTemplates={enabledPrivateDirectTemplates}
trigger={
<Button variant="outline">
<Trans>Link template</Trans>
<Trans context="Action button to link template to public profile">
Link template
</Trans>
</Button>
}
/>
@@ -8,17 +8,15 @@ import { Link, useNavigate } from 'react-router';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { EnvelopePdfViewer } from '@documenso/ui/components/pdf-viewer/envelope-pdf-viewer';
import { PDFViewer } from '@documenso/ui/components/pdf-viewer/pdf-viewer';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
@@ -52,14 +50,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery(
{
envelopeId: params.id,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
if (isLoadingEnvelope) {
return (
@@ -180,9 +173,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
{envelope.internalVersion === 2 ? (
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
@@ -196,10 +187,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<EnvelopePdfViewer
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef="window"
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</CardContent>
</Card>
@@ -220,12 +210,11 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
documentMeta={mockedDocumentMeta}
/>
<PDFViewer
<PDFViewerLazy
envelopeItem={envelope.envelopeItems[0]}
token={undefined}
version="current"
version="signed"
key={envelope.envelopeItems[0].id}
scrollParentRef="window"
/>
</CardContent>
</Card>
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { useParams, useSearchParams } from 'react-router';
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
@@ -34,7 +35,10 @@ export default function TemplatesPage() {
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
'templates-bulk-selection',
{},
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
@@ -51,11 +55,6 @@ export default function TemplatesPage() {
folderId,
});
// Clear selection when navigation or filters change
useEffect(() => {
setRowSelection({});
}, [folderId, page, perPage]);
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
@@ -66,7 +66,9 @@ export default function TemplatesFoldersPage() {
onClick={() => navigateToFolder(null)}
>
<HomeIcon className="h-4 w-4" />
<span>Home</span>
<span>
<Trans>Home</Trans>
</span>
</Button>
</div>
@@ -198,7 +198,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
{template.title}
</h1>
<div className="mb-8 mt-2.5 flex items-center gap-x-2 text-muted-foreground">
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
<UsersIcon className="h-4 w-4" />
<p className="text-muted-foreground/80">
<Plural value={template.recipients.length} one="# recipient" other="# recipients" />
@@ -246,12 +246,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
>
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>
@@ -494,12 +494,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
>
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>
@@ -1,3 +1,4 @@
import { Trans } from '@lingui/react/macro';
import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
import {
@@ -89,5 +90,9 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
}
}
return <div>Not Found</div>;
return (
<div>
<Trans>Not Found</Trans>
</div>
);
}
@@ -320,12 +320,7 @@ const EmbedDirectTemplatePageV2 = ({
user={user}
isDirectTemplate={true}
>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
>
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
<EmbedSignDocumentV2ClientPage
hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel}
@@ -382,12 +382,7 @@ const EmbedSignDocumentPageV2 = ({
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={token}
>
<EnvelopeRenderProvider envelope={envelope} token={token}>
<EmbedSignDocumentV2ClientPage
hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel}
-647
View File
@@ -1,647 +0,0 @@
/**
* This is an internal test page for the embedding system.
*
* We use this to test embeds for E2E testing.
*
* No translations required.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
export const loader = () => {
if (process.env.NODE_ENV !== 'development') {
throw new Error('This page is only available in development mode.');
}
};
/**
* Dummy embed test page.
*
* Simulates an embedding parent that renders the V2 authoring iframe
* with configurable features, externalId, and mode.
*
* Navigate to /embed/dummy to use.
*/
export default function EmbedDummyPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [token, setToken] = useState(() => searchParams.get('token') || '');
const [externalId, setExternalId] = useState(() => searchParams.get('externalId') || '');
const [mode, setMode] = useState<'create' | 'edit'>(
() => (searchParams.get('mode') as 'create' | 'edit') || 'create',
);
const [envelopeId, setEnvelopeId] = useState(() => searchParams.get('envelopeId') || '');
const [envelopeType, setEnvelopeType] = useState<'DOCUMENT' | 'TEMPLATE'>(
() => (searchParams.get('envelopeType') as 'DOCUMENT' | 'TEMPLATE') || 'DOCUMENT',
);
// Auto-launch if query params are present on mount
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const [iframeKey, setIframeKey] = useState(0);
const [messages, setMessages] = useState<string[]>([]);
// Feature flags state -- grouped by section
const [generalFeatures, setGeneralFeatures] = useState({
allowConfigureEnvelopeTitle: true,
allowUploadAndRecipientStep: true,
allowAddFieldsStep: true,
allowPreviewStep: true,
minimizeLeftSidebar: true,
});
const [settingsFeatures, setSettingsFeatures] = useState({
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
});
const [actionsFeatures, setActionsFeatures] = useState({
allowAttachments: true,
allowDistributing: false,
allowDirectLink: false,
allowDuplication: false,
allowDownloadPDF: false,
allowDeletion: false,
allowReturnToPreviousPage: false,
});
const [envelopeItemsFeatures, setEnvelopeItemsFeatures] = useState({
allowConfigureTitle: true,
allowConfigureOrder: true,
allowUpload: true,
allowDelete: true,
});
const [recipientsFeatures, setRecipientsFeatures] = useState({
allowAIDetection: true,
allowConfigureSigningOrder: true,
allowConfigureDictateNextSigner: true,
allowApproverRole: true,
allowViewerRole: true,
allowCCerRole: true,
allowAssistantRole: true,
});
const [fieldsFeatures, setFieldsFeatures] = useState({
allowAIDetection: true,
});
// CSS theming state
const [darkModeDisabled, setDarkModeDisabled] = useState(false);
const [rawCss, setRawCss] = useState('');
const [cssVars, setCssVars] = useState<Record<string, string>>({
background: '',
foreground: '',
muted: '',
mutedForeground: '',
popover: '',
popoverForeground: '',
card: '',
cardBorder: '',
cardBorderTint: '',
cardForeground: '',
fieldCard: '',
fieldCardBorder: '',
fieldCardForeground: '',
widget: '',
widgetForeground: '',
border: '',
input: '',
primary: '',
primaryForeground: '',
secondary: '',
secondaryForeground: '',
accent: '',
accentForeground: '',
destructive: '',
destructiveForeground: '',
ring: '',
radius: '',
warning: '',
});
const [isResolvingToken, setIsResolvingToken] = useState(false);
const [tokenError, setTokenError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const hasAutoLaunched = useRef(false);
/**
* If the token starts with "api_", exchange it for a presign token
* via the embedding presign endpoint. Otherwise return as-is.
*/
const resolveToken = async (inputToken: string): Promise<string> => {
if (!inputToken.startsWith('api_')) {
return inputToken;
}
const response = await fetch('/api/v2/embedding/create-presign-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${inputToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to exchange API token (${response.status}): ${text}`);
}
const data = await response.json();
const presignToken = data?.token;
if (!presignToken || typeof presignToken !== 'string') {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
return presignToken;
};
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const timestamp = new Date().toISOString().slice(11, 19);
setMessages((prev) => [...prev, `[${timestamp}] ${JSON.stringify(event.data, null, 2)}`]);
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-launch on mount if token is present in query params
useEffect(() => {
if (hasAutoLaunched.current) {
return;
}
const initialToken = searchParams.get('token');
if (initialToken) {
hasAutoLaunched.current = true;
void launchEmbed(initialToken);
}
}, []);
const updateQueryParams = (params: {
token: string;
externalId: string;
mode: string;
envelopeId: string;
envelopeType: string;
}) => {
const newParams = new URLSearchParams();
if (params.token) {
newParams.set('token', params.token);
}
if (params.externalId) {
newParams.set('externalId', params.externalId);
}
if (params.mode && params.mode !== 'create') {
newParams.set('mode', params.mode);
}
if (params.envelopeId) {
newParams.set('envelopeId', params.envelopeId);
}
if (params.envelopeType && params.envelopeType !== 'DOCUMENT') {
newParams.set('envelopeType', params.envelopeType);
}
const qs = newParams.toString();
void navigate(qs ? `?${qs}` : '.', { replace: true });
};
const launchEmbed = async (overrideToken?: string) => {
const inputToken = overrideToken ?? token;
if (!inputToken) {
return;
}
setTokenError(null);
setIsResolvingToken(true);
let presignToken: string;
try {
presignToken = await resolveToken(inputToken);
} catch (err) {
setTokenError(err instanceof Error ? err.message : String(err));
setIsResolvingToken(false);
return;
}
setIsResolvingToken(false);
// Filter out empty cssVars entries
const filteredCssVars: Record<string, string> = {};
for (const [key, value] of Object.entries(cssVars)) {
if (value) {
filteredCssVars[key] = value;
}
}
const hashData = {
externalId: externalId || undefined,
type: mode === 'create' ? envelopeType : undefined,
darkModeDisabled: darkModeDisabled || undefined,
css: rawCss || undefined,
cssVars: Object.keys(filteredCssVars).length > 0 ? filteredCssVars : undefined,
features: {
general: generalFeatures,
settings: settingsFeatures,
actions: actionsFeatures,
envelopeItems: envelopeItemsFeatures,
recipients: recipientsFeatures,
fields: fieldsFeatures,
},
};
const hash = btoa(encodeURIComponent(JSON.stringify(hashData)));
const basePath =
mode === 'create'
? '/embed/v2/authoring/envelope/create'
: `/embed/v2/authoring/envelope/edit/${envelopeId}`;
setIframeSrc(`${basePath}?token=${presignToken}#${hash}`);
setIframeKey((prev) => prev + 1);
updateQueryParams({ token: inputToken, externalId, mode, envelopeId, envelopeType });
};
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
void launchEmbed();
},
[
token,
externalId,
mode,
envelopeId,
envelopeType,
generalFeatures,
settingsFeatures,
actionsFeatures,
envelopeItemsFeatures,
recipientsFeatures,
fieldsFeatures,
darkModeDisabled,
rawCss,
cssVars,
],
);
const handleClear = () => {
setToken('');
setExternalId('');
setMode('create');
setEnvelopeId('');
setEnvelopeType('DOCUMENT');
setIframeSrc(null);
setMessages([]);
setTokenError(null);
setDarkModeDisabled(false);
setRawCss('');
setCssVars((prev) => {
const cleared: Record<string, string> = {};
for (const key of Object.keys(prev)) {
cleared[key] = '';
}
return cleared;
});
void navigate('.', { replace: true });
};
const renderCheckboxGroup = <T extends Record<string, boolean>>(
label: string,
state: T,
setState: React.Dispatch<React.SetStateAction<T>>,
) => (
<fieldset
style={{ border: '1px solid #ccc', padding: '8px', marginBottom: '8px', borderRadius: '4px' }}
>
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>{label}</legend>
{Object.entries(state).map(([key, value]) => (
<label key={key} style={{ display: 'block', fontSize: '12px', marginBottom: '2px' }}>
<input
type="checkbox"
checked={value}
onChange={(e) => setState((prev) => ({ ...prev, [key]: e.target.checked }))}
style={{ marginRight: '4px' }}
/>
{key}
</label>
))}
</fieldset>
);
return (
<div style={{ display: 'flex', height: '100vh', fontFamily: 'monospace' }}>
{/* Left panel: controls */}
<div
style={{
width: '320px',
padding: '12px',
borderRight: '1px solid #ccc',
overflowY: 'auto',
flexShrink: 0,
}}
>
<h2 style={{ margin: '0 0 12px', fontSize: '16px' }}>Embed Test</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
API or Embedded Token (Required)
</label>
<input
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
placeholder="api_... or presign token"
required
/>
{tokenError && (
<div style={{ color: 'red', fontSize: '11px', marginTop: '4px' }}>{tokenError}</div>
)}
</div>
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
External ID (optional)
</label>
<input
type="text"
value={externalId}
onChange={(e) => setExternalId(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
placeholder="your-correlation-id"
/>
</div>
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>Mode</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value as 'create' | 'edit')}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
>
<option value="create">Create</option>
<option value="edit">Edit</option>
</select>
</div>
{mode === 'create' && (
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
Envelope Type
</label>
<select
value={envelopeType}
onChange={(e) => setEnvelopeType(e.target.value as 'DOCUMENT' | 'TEMPLATE')}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
>
<option value="DOCUMENT">Document</option>
<option value="TEMPLATE">Template</option>
</select>
</div>
)}
{mode === 'edit' && (
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 'bold' }}>
Envelope ID
</label>
<input
type="text"
value={envelopeId}
onChange={(e) => setEnvelopeId(e.target.value)}
style={{ width: '100%', padding: '4px', fontSize: '12px' }}
placeholder="envelope_..."
required
/>
</div>
)}
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>Feature Flags</h3>
{renderCheckboxGroup('General', generalFeatures, setGeneralFeatures)}
{renderCheckboxGroup('Settings', settingsFeatures, setSettingsFeatures)}
{renderCheckboxGroup('Actions', actionsFeatures, setActionsFeatures)}
{renderCheckboxGroup('Envelope Items', envelopeItemsFeatures, setEnvelopeItemsFeatures)}
{renderCheckboxGroup('Recipients', recipientsFeatures, setRecipientsFeatures)}
{renderCheckboxGroup('Fields', fieldsFeatures, setFieldsFeatures)}
<h3 style={{ fontSize: '14px', margin: '12px 0 4px' }}>CSS Theming</h3>
<label style={{ display: 'block', fontSize: '12px', marginBottom: '8px' }}>
<input
type="checkbox"
checked={darkModeDisabled}
onChange={(e) => setDarkModeDisabled(e.target.checked)}
style={{ marginRight: '4px' }}
/>
darkModeDisabled
</label>
<fieldset
style={{
border: '1px solid #ccc',
padding: '8px',
marginBottom: '8px',
borderRadius: '4px',
}}
>
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>CSS Variables</legend>
<div
style={{
maxHeight: '200px',
overflowY: 'auto',
}}
>
{Object.entries(cssVars).map(([key, value]) => (
<div
key={key}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginBottom: '2px',
}}
>
<label style={{ fontSize: '11px', width: '140px', flexShrink: 0 }}>{key}</label>
{key !== 'radius' && (
<input
type="color"
value={value || '#000000'}
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
/>
)}
<input
type="text"
value={value}
onChange={(e) => setCssVars((prev) => ({ ...prev, [key]: e.target.value }))}
style={{ flex: 1, padding: '2px 4px', fontSize: '11px' }}
placeholder={key === 'radius' ? '0.5rem' : '#hex or color'}
/>
{value && (
<button
type="button"
onClick={() => setCssVars((prev) => ({ ...prev, [key]: '' }))}
style={{
fontSize: '10px',
cursor: 'pointer',
padding: '0 4px',
lineHeight: '18px',
}}
>
x
</button>
)}
</div>
))}
</div>
</fieldset>
<fieldset
style={{
border: '1px solid #ccc',
padding: '8px',
marginBottom: '8px',
borderRadius: '4px',
}}
>
<legend style={{ fontWeight: 'bold', fontSize: '13px' }}>Raw CSS</legend>
<textarea
value={rawCss}
onChange={(e) => setRawCss(e.target.value)}
style={{
width: '100%',
height: '80px',
padding: '4px',
fontSize: '11px',
fontFamily: 'monospace',
resize: 'vertical',
}}
placeholder=".my-class { color: red; }"
/>
</fieldset>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<button
type="submit"
disabled={isResolvingToken}
style={{
flex: 1,
padding: '8px',
fontSize: '13px',
fontWeight: 'bold',
cursor: isResolvingToken ? 'not-allowed' : 'pointer',
opacity: isResolvingToken ? 0.6 : 1,
}}
>
{isResolvingToken ? 'Resolving Token...' : 'Launch Embed'}
</button>
<button
type="button"
onClick={handleClear}
style={{
padding: '8px 12px',
fontSize: '13px',
cursor: 'pointer',
}}
>
Clear
</button>
</div>
</form>
{/* Message log */}
<div style={{ marginTop: '16px' }}>
<h3 style={{ fontSize: '14px', margin: '0 0 4px' }}>
PostMessage Log
{messages.length > 0 && (
<button
type="button"
onClick={() => setMessages([])}
style={{ marginLeft: '8px', fontSize: '10px', cursor: 'pointer' }}
>
clear
</button>
)}
</h3>
<div
style={{
height: '200px',
overflowY: 'auto',
border: '1px solid #ccc',
padding: '4px',
fontSize: '11px',
backgroundColor: '#f9f9f9',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{messages.length === 0 && (
<span style={{ color: '#999' }}>Waiting for messages...</span>
)}
{messages.map((msg, i) => (
<div key={i} style={{ borderBottom: '1px solid #eee', padding: '2px 0' }}>
{msg}
</div>
))}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{/* Right panel: iframe */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{iframeSrc ? (
<iframe
key={iframeKey}
src={iframeSrc}
style={{ flex: 1, border: 'none', width: '100%', height: '100%' }}
title="Embedded Authoring"
/>
) : (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontSize: '14px',
}}
>
Enter a token and click "Launch Embed" to start
</div>
)}
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import { useLayoutEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { Outlet, useLoaderData } from 'react-router';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
@@ -75,7 +76,11 @@ export default function AuthoringLayout() {
}, []);
if (!hasValidToken) {
return <div>Invalid embedding presign token provided</div>;
return (
<div>
<Trans>Invalid embedding presign token provided</Trans>
</div>
);
}
return (
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { useRevalidator } from 'react-router';
@@ -283,7 +284,9 @@ export default function MultisignPage() {
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -298,7 +301,9 @@ export default function MultisignPage() {
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -1,181 +0,0 @@
import { useLayoutEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
import { Outlet, isRouteErrorResponse, useLoaderData } from 'react-router';
import { match } from 'ts-pattern';
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { TrpcProvider } from '@documenso/trpc/react';
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import { TeamProvider } from '~/providers/team';
import { ZBaseEmbedDataSchema } from '~/types/embed-base-schemas';
import { injectCss } from '~/utils/css-vars';
import type { Route } from './+types/_layout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Response('Invalid token', { status: 404 });
}
const organisationClaim = await getOrganisationClaimByTeamId({
teamId: result.teamId,
});
const teamSettings = await getTeamSettings({
userId: result.userId,
teamId: result.teamId,
});
return {
token,
userId: result.userId,
teamId: result.teamId,
organisationClaim,
preferences: {
aiFeaturesEnabled: teamSettings.aiFeaturesEnabled,
},
};
};
export default function AuthoringLayout() {
const { token, teamId, organisationClaim, preferences } = useLoaderData<typeof loader>();
const allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
const result = ZBaseEmbedDataSchema.safeParse(JSON.parse(decodeURIComponent(atob(hash))));
if (!result.success) {
return;
}
const { css, cssVars, darkModeDisabled } = result.data;
if (darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (allowEmbedAuthoringWhiteLabel) {
injectCss({
css,
cssVars,
});
}
} catch (error) {
console.error(error);
}
}, []);
/**
* Dummy data for providers.
*/
const team: OrganisationSession['teams'][number] = {
id: teamId,
name: '',
url: '',
createdAt: new Date(),
avatarImageId: null,
organisationId: '',
currentTeamRole: TeamMemberRole.ADMIN,
preferences: {
aiFeaturesEnabled: preferences.aiFeaturesEnabled,
},
};
/**
* Dummy data for providers.
*/
const organisation: OrganisationSession = {
id: '',
createdAt: new Date(),
updatedAt: new Date(),
type: OrganisationType.ORGANISATION,
name: '',
url: '',
avatarImageId: null,
customerId: null,
ownerUserId: -1,
organisationClaim,
teams: [team],
subscription: null,
currentOrganisationRole: OrganisationMemberRole.ADMIN,
};
return (
<OrganisationProvider organisation={organisation}>
<TeamProvider team={team}>
<TrpcProvider
headers={{ authorization: `Bearer ${token}`, 'x-team-Id': team.id.toString() }}
>
<LimitsProvider
bypassLimits={true}
initialValue={{
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount: organisationClaim.envelopeItemCount,
}}
teamId={team.id}
>
<Outlet />
</LimitsProvider>
</TrpcProvider>
</TeamProvider>
</OrganisationProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
return (
<div>
{match(errorCode)
.with(404, () => (
<div>
<p>
<Trans>Token Not Found</Trans>
</p>
<ul>
<li>
<Trans>Ensure that you are using the embedding token, not the API token</Trans>
</li>
<li>
<Trans>
If you are using staging, ensure that you have set the host prop on the embedding
component to the staging domain (https://stg-app.documenso.com)
</Trans>
</li>
</ul>
</div>
))
.otherwise(() => (
<p>
<Trans>An error occurred</Trans>
{errorCode}
</p>
))}
</div>
);
}
@@ -1,404 +0,0 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import {
DocumentStatus,
EnvelopeType,
ReadStatus,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { CheckCircle2Icon } from 'lucide-react';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import {
type TEmbedCreateEnvelopeAuthoring,
ZEmbedCreateEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
import { buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/envelope.create._index';
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
// We know that the token is present because we're checking it in the parent _layout route
const token = url.searchParams.get('token') || '';
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Response('Invalid token', { status: 404 });
}
const teamSettings = await getTeamSettings({
userId: result.userId,
teamId: result.teamId,
});
return superLoaderJson({
token,
tokenUserId: result.userId,
tokenTeamId: result.teamId,
teamSettings,
});
};
export default function EmbeddingAuthoringEnvelopeCreatePage() {
const [hasInitialized, setHasInitialized] = useState(false);
const [embedAuthoringOptions, setEmbedAuthoringOptions] =
useState<TEmbedCreateEnvelopeAuthoring | null>(null);
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
if (hash) {
const result = ZEmbedCreateEnvelopeAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (result.success) {
setEmbedAuthoringOptions({
...result.data,
features: buildEmbeddedFeatures(result.data.features),
});
}
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
setHasInitialized(true);
}, []);
if (!hasInitialized) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner />
</div>
);
}
if (!embedAuthoringOptions) {
return (
<div className="flex min-h-screen items-center justify-center">
<Trans>Invalid Embedding Parameters</Trans>
</div>
);
}
return <EnvelopeCreatePage embedAuthoringOptions={embedAuthoringOptions} />;
}
type EnvelopeCreatePageProps = {
embedAuthoringOptions: TEmbedCreateEnvelopeAuthoring;
};
const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps) => {
const { token, tokenUserId, tokenTeamId, teamSettings } = useSuperLoaderData<typeof loader>();
const { t } = useLingui();
const { toast } = useToast();
const [isCreatingEnvelope, setIsCreatingEnvelope] = useState(false);
const [createdEnvelope, setCreatedEnvelope] = useState<{ id: string } | null>(null);
const { mutateAsync: createEmbeddingEnvelope } =
trpc.embeddingPresign.createEmbeddingEnvelope.useMutation();
const buildCreateEnvelopeRequest = (
envelope: Omit<TEditorEnvelope, 'id'>,
): { payload: TCreateEnvelopePayload; files: File[] } => {
const sortedItems = [...envelope.envelopeItems].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const itemIdToIndex = new Map<string, number>();
sortedItems.forEach((item, index) => {
itemIdToIndex.set(String(item.id), index);
});
const files: File[] = [];
for (const item of sortedItems) {
if (!item.data) {
throw new Error(`Envelope item "${item.title ?? item.id}" has no PDF data`);
}
files.push(
new File(
[item.data],
item.title?.endsWith('.pdf') ? item.title : `${item.title ?? 'document'}.pdf`,
{
type: 'application/pdf',
},
),
);
}
const recipients = envelope.recipients.map((recipient) => {
const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id);
const fields = recipientFields.map((field) => {
return {
identifier: itemIdToIndex.get(String(field.envelopeItemId)),
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
...({
type: field.type,
fieldMeta: field.fieldMeta ?? undefined,
} as TEnvelopeFieldAndMeta),
};
});
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder ?? undefined,
accessAuth: recipient.authOptions?.accessAuth ?? [],
actionAuth: recipient.authOptions?.actionAuth ?? [],
fields,
};
});
const payload: TCreateEnvelopePayload = {
title: envelope.title,
type: envelope.type,
externalId: envelope.externalId ?? undefined,
visibility: envelope.visibility,
globalAccessAuth: envelope.authOptions?.globalAccessAuth?.length
? envelope.authOptions?.globalAccessAuth
: undefined,
globalActionAuth: envelope.authOptions?.globalActionAuth?.length
? envelope.authOptions?.globalActionAuth
: undefined,
folderId: envelope.folderId ?? undefined,
recipients,
meta: {
subject: envelope.documentMeta.subject ?? undefined,
message: envelope.documentMeta.message ?? undefined,
timezone: envelope.documentMeta.timezone ?? undefined,
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
distributionMethod: envelope.documentMeta.distributionMethod ?? undefined,
signingOrder: envelope.documentMeta.signingOrder ?? undefined,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined,
redirectUrl: envelope.documentMeta.redirectUrl ?? undefined,
language: envelope.documentMeta.language as SupportedLanguageCodes,
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
emailId: envelope.documentMeta.emailId ?? undefined,
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
emailSettings: envelope.documentMeta.emailSettings ?? undefined,
},
};
return { payload, files };
};
const createEmbeddedEnvelope = async (envelopeWithoutId: Omit<TEditorEnvelope, 'id'>) => {
setIsCreatingEnvelope(true);
if (isCreatingEnvelope) {
return;
}
try {
const { payload, files } = buildCreateEnvelopeRequest(envelopeWithoutId);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'envelope-created',
envelopeId: id,
externalId: envelopeWithoutId.externalId,
},
'*',
);
}
setCreatedEnvelope({ id });
} catch (err) {
console.error('Failed to create envelope:', err);
toast({
variant: 'destructive',
title: t`Error`,
description: t`Failed to create document. Please try again.`,
});
}
setIsCreatingEnvelope(false);
};
const embeded = useMemo(
() => ({
presignToken: token,
mode: 'create' as const,
onCreate: async (envelope: Omit<TEditorEnvelope, 'id'>) => createEmbeddedEnvelope(envelope),
customBrandingLogo: Boolean(teamSettings.brandingEnabled && teamSettings.brandingLogo),
}),
[token],
);
const editorConfig = useMemo(() => {
return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embeded);
}, [embedAuthoringOptions.features, embeded]);
const initialEnvelope = useMemo((): TEditorEnvelope => {
const defaultDocumentMeta = extractDerivedDocumentMeta(teamSettings, undefined);
const defaultRecipients = teamSettings.defaultRecipients
? ZDefaultRecipientsSchema.parse(teamSettings.defaultRecipients)
: [];
const recipients: TEditorEnvelope['recipients'] = defaultRecipients.map((recipient, index) => ({
id: -(index + 1),
envelopeId: '',
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: '',
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.NOT_SENT,
documentDeletedAt: null,
expired: null,
signedAt: null,
authOptions: {
accessAuth: [],
actionAuth: [],
},
signingOrder: index + 1,
rejectionReason: null,
}));
const type = embedAuthoringOptions.type;
return {
id: '',
secondaryId: '',
internalVersion: 2,
type,
status: DocumentStatus.DRAFT,
source: 'DOCUMENT',
visibility: teamSettings.documentVisibility,
templateType: 'PRIVATE',
completedAt: null,
deletedAt: null,
title: type === EnvelopeType.DOCUMENT ? 'Document Title' : 'Template Title',
authOptions: {
globalAccessAuth: [],
globalActionAuth: [],
},
publicTitle: '',
publicDescription: '',
userId: tokenUserId,
teamId: tokenTeamId,
folderId: null,
documentMeta: {
id: '',
...defaultDocumentMeta,
},
recipients,
fields: [],
envelopeItems: [],
directLink: null,
team: {
id: tokenTeamId,
url: '',
},
user: {
id: tokenUserId,
name: '',
email: '',
},
externalId: embedAuthoringOptions?.externalId ?? null,
};
}, []);
return (
<div className="min-w-screen relative min-h-screen">
{isCreatingEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<Spinner />
<p className="mt-2 text-sm text-muted-foreground">
{initialEnvelope.type === EnvelopeType.DOCUMENT ? (
<Trans>Creating Document</Trans>
) : (
<Trans>Creating Template</Trans>
)}
</p>
</div>
)}
{createdEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="mx-auto w-full max-w-md text-center">
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
<h1 className="mt-6 text-2xl font-bold">
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Template Created</Trans>
) : (
<Trans>Document Created</Trans>
)}
</h1>
<p className="mt-2 text-muted-foreground">
{initialEnvelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Your template has been created successfully</Trans>
) : (
<Trans>Your document has been created successfully</Trans>
)}
</p>
</div>
</div>
)}
<EnvelopeEditorProvider initialEnvelope={initialEnvelope} editorConfig={editorConfig}>
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
<EnvelopeEditor />
</EnvelopeEditorRenderProviderWrapper>
</EnvelopeEditorProvider>
</div>
);
};
@@ -1,353 +0,0 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { CheckCircle2Icon } from 'lucide-react';
import { redirect } from 'react-router';
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import {
type TEmbedEditEnvelopeAuthoring,
ZEmbedEditEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { buildEmbeddedEditorOptions } from '@documenso/lib/utils/embed-config';
import { trpc } from '@documenso/trpc/react';
import type { TUpdateEmbeddingEnvelopePayload } from '@documenso/trpc/server/embedding-router/update-embedding-envelope.types';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeEditor } from '~/components/general/envelope-editor/envelope-editor';
import { EnvelopeEditorRenderProviderWrapper } from '~/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/envelope.edit.$id';
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const url = new URL(request.url);
const { id } = params;
if (!id || !id.startsWith('envelope_')) {
throw redirect(`/embed/v2/authoring/error/not-found`);
}
// We know that the token is present because we're checking it in the parent _layout route
const token = url.searchParams.get('token') || '';
if (!token) {
throw new Response('Invalid token', { status: 404 });
}
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token, scope: `envelopeId:${id}` }).catch(
() => null,
);
if (!result) {
throw new Error('Invalid token');
}
const settings = await getTeamSettings({
userId: result.userId,
teamId: result.teamId,
});
const envelope = await getEnvelopeById({
id: {
type: 'envelopeId',
id,
},
type: null,
userId: result.userId,
teamId: result.teamId,
}).catch(() => null);
if (!envelope) {
throw redirect(`/embed/v2/authoring/error/not-found`);
}
let brandingLogo: string | undefined = undefined;
if (settings.brandingEnabled && settings.brandingLogo) {
brandingLogo = settings.brandingLogo;
}
return superLoaderJson({
token,
envelope,
brandingLogo,
});
};
export default function EmbeddingAuthoringEnvelopeEditPage() {
const [hasInitialized, setHasInitialized] = useState(false);
const [embedAuthoringOptions, setEmbedAuthoringOptions] =
useState<TEmbedEditEnvelopeAuthoring | null>(null);
useLayoutEffect(() => {
try {
const hash = window.location.hash.slice(1);
if (hash) {
const result = ZEmbedEditEnvelopeAuthoringSchema.safeParse(
JSON.parse(decodeURIComponent(atob(hash))),
);
if (result.success) {
setEmbedAuthoringOptions(result.data);
}
}
} catch (err) {
console.error('Error parsing embedding params:', err);
}
setHasInitialized(true);
}, []);
if (!hasInitialized) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner />
</div>
);
}
if (!embedAuthoringOptions) {
return (
<div className="flex min-h-screen items-center justify-center">
<Trans>Invalid Embedding Parameters</Trans>
</div>
);
}
return <EnvelopeEditPage embedAuthoringOptions={embedAuthoringOptions} />;
}
type EnvelopeEditPageProps = {
embedAuthoringOptions: TEmbedEditEnvelopeAuthoring;
};
const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
const { envelope, token, brandingLogo } = useSuperLoaderData<typeof loader>();
const { t } = useLingui();
const { toast } = useToast();
const [isUpdatingEnvelope, setIsUpdatingEnvelope] = useState(false);
const [updatedEnvelope, setUpdatedEnvelope] = useState<{ id: string } | null>(null);
const { mutateAsync: updateEmbeddingEnvelope } =
trpc.embeddingPresign.updateEmbeddingEnvelope.useMutation();
const buildUpdateEnvelopeRequest = (
envelope: TEditorEnvelope,
): { payload: TUpdateEmbeddingEnvelopePayload; files: File[] } => {
const files: File[] = [];
const envelopeItems = envelope.envelopeItems.map((item) => {
// Attach any new envelope item files to the request.
if (item.data) {
files.push(
new File(
[item.data],
item.title?.endsWith('.pdf') ? item.title : `${item.title ?? 'document'}.pdf`,
{
type: 'application/pdf',
},
),
);
}
return {
id: item.id,
title: item.title,
order: item.order,
index: item.data ? files.length - 1 : undefined,
};
});
const recipients = envelope.recipients.map((recipient) => {
const recipientFields = envelope.fields.filter((f) => f.recipientId === recipient.id);
const fields = recipientFields.map((field) => ({
id: field.id,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
...({
type: field.type,
fieldMeta: field.fieldMeta ?? undefined,
} as TEnvelopeFieldAndMeta),
}));
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder ?? undefined,
accessAuth: recipient.authOptions?.accessAuth ?? [],
actionAuth: recipient.authOptions?.actionAuth ?? [],
fields,
};
});
const payload: TUpdateEmbeddingEnvelopePayload = {
envelopeId: envelope.id,
data: {
title: envelope.title,
externalId: envelope.externalId,
visibility: envelope.visibility,
globalAccessAuth: envelope.authOptions?.globalAccessAuth,
globalActionAuth: envelope.authOptions?.globalActionAuth,
folderId: envelope.folderId,
recipients,
envelopeItems,
},
meta: {
subject: envelope.documentMeta.subject ?? undefined,
message: envelope.documentMeta.message ?? undefined,
timezone: envelope.documentMeta.timezone ?? undefined,
distributionMethod: envelope.documentMeta.distributionMethod ?? undefined,
signingOrder: envelope.documentMeta.signingOrder ?? undefined,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner ?? undefined,
redirectUrl: envelope.documentMeta.redirectUrl ?? undefined,
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
emailId: envelope.documentMeta.emailId ?? undefined,
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
emailSettings: envelope.documentMeta.emailSettings ?? undefined,
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
language: envelope.documentMeta.language as SupportedLanguageCodes,
},
};
return { payload, files };
};
const updateEmbeddedEnvelope = async (envelope: TEditorEnvelope) => {
setIsUpdatingEnvelope(true);
if (isUpdatingEnvelope) {
return;
}
try {
const { payload, files } = buildUpdateEnvelopeRequest(envelope);
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
await updateEmbeddingEnvelope(formData);
// Send a message to the parent window with the document details
if (window.parent !== window) {
window.parent.postMessage(
{
type: 'envelope-updated',
envelopeId: envelope.id,
externalId: envelope.externalId || null,
},
'*',
);
}
// Navigate to the completion page.
setUpdatedEnvelope({ id: envelope.id });
} catch (err) {
console.error('Failed to update envelope:', err);
toast({
variant: 'destructive',
title: t`Error`,
description: t`Failed to update envelope. Please try again.`,
});
}
setIsUpdatingEnvelope(false);
};
const embeded = useMemo(
() => ({
presignToken: token,
mode: 'edit' as const,
onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope),
brandingLogo,
}),
[token],
);
const editorConfig = useMemo(() => {
return buildEmbeddedEditorOptions(embedAuthoringOptions.features, embeded);
}, [embedAuthoringOptions.features, embeded]);
const initialEnvelope = useMemo(
() => ({
...envelope,
externalId: embedAuthoringOptions?.externalId || envelope.externalId || null,
}),
[envelope, embedAuthoringOptions?.externalId],
);
return (
<div className="min-w-screen relative min-h-screen">
{isUpdatingEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<Spinner />
<p className="mt-2 text-sm text-muted-foreground">
{envelope.type === EnvelopeType.DOCUMENT ? (
<Trans>Updating Document</Trans>
) : (
<Trans>Updating Template</Trans>
)}
</p>
</div>
)}
{updatedEnvelope && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-background">
<div className="mx-auto w-full max-w-md text-center">
<CheckCircle2Icon className="mx-auto h-16 w-16 text-primary" />
<h1 className="mt-6 text-2xl font-bold">
{envelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Template Updated</Trans>
) : (
<Trans>Document Updated</Trans>
)}
</h1>
<p className="mt-2 text-muted-foreground">
{envelope.type === EnvelopeType.TEMPLATE ? (
<Trans>Your template has been updated successfully</Trans>
) : (
<Trans>Your document has been updated successfully</Trans>
)}
</p>
</div>
</div>
)}
<EnvelopeEditorProvider initialEnvelope={initialEnvelope} editorConfig={editorConfig}>
<EnvelopeEditorRenderProviderWrapper presignedToken={token}>
<EnvelopeEditor />
</EnvelopeEditorRenderProviderWrapper>
</EnvelopeEditorProvider>
</div>
);
};
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.6.0"
"version": "2.6.1"
}
-12
View File
@@ -23,10 +23,6 @@ import {
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getEnvelopeItemImageRoute from './routes/get-envelope-item-image';
import getEnvelopeItemImageByTokenRoute from './routes/get-envelope-item-image-by-token';
import getEnvelopeItemMetaRoute from './routes/get-envelope-item-meta';
import getEnvelopeItemMetaByTokenRoute from './routes/get-envelope-item-meta-by-token';
export const filesRoute = new Hono<HonoEnv>()
/**
@@ -323,11 +319,3 @@ export const filesRoute = new Hono<HonoEnv>()
});
},
);
// Envelope item meta routes for both tokens and auth based
filesRoute.route('/', getEnvelopeItemMetaRoute);
filesRoute.route('/', getEnvelopeItemMetaByTokenRoute);
// Image routes for both tokens and auth based
filesRoute.route('/', getEnvelopeItemImageRoute);
filesRoute.route('/', getEnvelopeItemImageByTokenRoute);
@@ -72,23 +72,3 @@ export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema
>;
export const ZGetEnvelopeItemMetaSchema = z.object({
envelopeItemId: z.string(),
documentDataId: z.string(),
pages: z
.object({
originalWidth: z.number(),
originalHeight: z.number(),
scale: z.number(),
scaledWidth: z.number(),
scaledHeight: z.number(),
})
.array(),
});
export const ZGetEnvelopeItemsMetaResponseSchema = z.object({
envelopeItems: z.array(ZGetEnvelopeItemMetaSchema),
});
export type TGetEnvelopeItemsMetaResponse = z.infer<typeof ZGetEnvelopeItemsMetaResponseSchema>;
@@ -1,68 +0,0 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import { handleEnvelopeItemPageRequest } from './get-envelope-item-image';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeItemPageTokenParamsSchema = z.object({
token: z.string().min(1),
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
documentDataId: z.string().min(1),
version: z.enum(['initial', 'current']),
pageIndex: z.coerce.number().int().min(0),
});
/**
* Returns a single PDF page as a JPEG image using a token.
*/
route.get(
'/token/:token/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
sValidator('param', ZGetEnvelopeItemPageTokenParamsSchema),
async (c) => {
const { token, envelopeId, envelopeItemId, documentDataId, version, pageIndex } =
c.req.valid('param');
// Validate envelope access.
const envelopeItem = await prisma.envelopeItem.findFirst({
where: {
id: envelopeItemId,
documentDataId,
envelope: {
id: envelopeId,
recipients: {
some: {
token,
},
},
},
},
include: {
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Not found' }, 404);
}
// We can hard cache this since since it's a unique URL for a given recipient.
// Might be dicey if the handler returns a cacheable error code.
c.header('Cache-Control', 'public, max-age=31536000, immutable');
return await handleEnvelopeItemPageRequest({
c,
envelopeItem,
version,
pageIndex,
cacheStrategy: 'public',
});
},
);
export default route;
@@ -1,175 +0,0 @@
import { sValidator } from '@hono/standard-validator';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { type Context, Hono } from 'hono';
import { z } from 'zod';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { PDF_IMAGE_RENDER_SCALE } from '@documenso/lib/constants/pdf-viewer';
import { pdfToImage } from '@documenso/lib/server-only/ai/pdf-to-images';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import type { DocumentDataVersion } from '@documenso/lib/types/document-data';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { UNSAFE_getS3File } from '@documenso/lib/universal/upload/server-actions';
import { getEnvelopeItemPageImageS3Key } from '@documenso/lib/utils/envelope-images';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeItemPageRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
documentDataId: z.string().min(1),
version: z.enum(['initial', 'current']),
pageIndex: z.coerce.number().int().min(0),
});
const ZGetEnvelopeItemPageRequestQuerySchema = z.object({
presignToken: z.string().optional(),
});
/**
* Returns a single PDF page as a JPEG image.
*/
route.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/:pageIndex/image.jpeg',
sValidator('param', ZGetEnvelopeItemPageRequestParamsSchema),
sValidator('query', ZGetEnvelopeItemPageRequestQuerySchema),
async (c) => {
const { envelopeId, envelopeItemId, documentDataId, version, pageIndex } = c.req.valid('param');
const { presignToken } = c.req.valid('query');
const session = await getOptionalSession(c);
let userId = session.user?.id;
// Check presignToken if provided
if (presignToken) {
const verifiedToken = await verifyEmbeddingPresignToken({
token: presignToken,
}).catch(() => undefined);
userId = verifiedToken?.userId;
}
if (!userId) {
return c.json({ error: 'Not found' }, 404);
}
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
include: {
envelopeItems: {
where: {
id: envelopeItemId,
documentDataId,
},
include: {
documentData: true,
},
},
},
});
if (!envelope) {
return c.json({ error: 'Not found' }, 404);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem?.documentData) {
return c.json({ error: 'Not found' }, 404);
}
// Check team access
const team = await getTeamById({
userId,
teamId: envelope.teamId,
}).catch(() => null);
if (!team) {
return c.json({ error: 'Not found' }, 404);
}
return await handleEnvelopeItemPageRequest({
c,
envelopeItem,
version,
pageIndex,
cacheStrategy: 'private',
});
},
);
type HandleEnvelopeItemPageRequestOptions = {
c: Context<HonoEnv>;
envelopeItem: EnvelopeItem & {
documentData: DocumentData;
};
pageIndex: number;
version: DocumentDataVersion;
/**
* The type of cache strategy to use.
*
* For access via tokens, we can use a public cache to allow the CDN to cache it.
*
* For access via session, we must use a private cache.
*/
cacheStrategy: 'private' | 'public';
};
export const handleEnvelopeItemPageRequest = async ({
c,
envelopeItem,
pageIndex,
version,
cacheStrategy,
}: HandleEnvelopeItemPageRequestOptions) => {
// Determine which PDF data to use based on version requested.
const documentDataToUse =
version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData;
c.header('Content-Type', 'image/jpeg');
c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`);
// Return the image if it already exists in S3.
if (envelopeItem.documentData.type === 'S3_PATH') {
const s3Key = getEnvelopeItemPageImageS3Key(documentDataToUse, pageIndex);
const image = await UNSAFE_getS3File(s3Key);
if (image) {
return c.body(image);
}
}
// Fetch PDF to render the page on the spot if it doesn't exist in S3.
const pdfBytes = await getFileServerSide({
type: envelopeItem.documentData.type,
data: documentDataToUse,
});
// Render page to image.
const { image } = await pdfToImage(pdfBytes, {
scale: PDF_IMAGE_RENDER_SCALE,
pageIndex,
}).catch((err) => {
console.error(err);
return {
image: null,
};
});
if (!image) {
return c.json({ error: 'Failed to render page to image' }, 500);
}
return c.body(image);
};
export default route;
@@ -1,54 +0,0 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import { handleEnvelopeItemsMetaRequest } from './get-envelope-item-meta';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeMetaByTokenParamSchema = z.object({
token: z.string().min(1),
envelopeId: z.string().min(1),
});
/**
* Returns metadata for all envelope items including page counts and dimensions using a token.
*/
route.get(
'/token/:token/envelope/:envelopeId/meta',
sValidator('param', ZGetEnvelopeMetaByTokenParamSchema),
async (c) => {
const { token, envelopeId } = c.req.valid('param');
// Validate token belongs to envelope
const recipient = await prisma.recipient.findFirst({
where: {
token,
envelopeId,
},
select: {
envelope: {
include: {
envelopeItems: {
include: { documentData: true },
},
},
},
},
});
if (!recipient) {
return c.json({ error: 'Not found' }, 404);
}
return await handleEnvelopeItemsMetaRequest({
c,
envelopeItems: recipient.envelope.envelopeItems,
});
},
);
export default route;
@@ -1,139 +0,0 @@
import { sValidator } from '@hono/standard-validator';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { type Context, Hono } from 'hono';
import { z } from 'zod';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import type { TDocumentDataMeta } from '@documenso/lib/types/document-data';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { extractAndStorePdfImages } from '@documenso/lib/universal/upload/put-file.server';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../../router';
import type { TGetEnvelopeItemsMetaResponse } from '../files.types';
const route = new Hono<HonoEnv>();
const ZGetEnvelopeMetaParamsSchema = z.object({
envelopeId: z.string().min(1),
});
const ZGetEnvelopeMetaQuerySchema = z.object({
presignToken: z.string().optional(),
});
/**
* Returns metadata for all envelope items including page counts and dimensions.
*/
route.get(
'/envelope/:envelopeId/meta',
sValidator('param', ZGetEnvelopeMetaParamsSchema),
sValidator('query', ZGetEnvelopeMetaQuerySchema),
async (c) => {
const { envelopeId } = c.req.valid('param');
const { presignToken } = c.req.valid('query');
const session = await getOptionalSession(c);
let userId = session.user?.id;
// Check presignToken if provided
if (presignToken) {
const verifiedToken = await verifyEmbeddingPresignToken({
token: presignToken,
}).catch(() => undefined);
userId = verifiedToken?.userId;
}
if (!userId) {
return c.json({ error: 'Not found' }, 404);
}
// Note: Access is verified in the getTeamById call after this.
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
include: { documentData: true },
},
},
});
if (!envelope) {
return c.json({ error: 'Not found' }, 404);
}
// Check access to envelope.
const team = await getTeamById({
userId,
teamId: envelope.teamId,
}).catch(() => null);
if (!team) {
return c.json({ error: 'Not found' }, 404);
}
return await handleEnvelopeItemsMetaRequest({
c,
envelopeItems: envelope.envelopeItems,
});
},
);
type HandleEnvelopeItemsMetaRequestOptions = {
c: Context<HonoEnv>;
envelopeItems: (EnvelopeItem & {
documentData: DocumentData;
})[];
};
export const handleEnvelopeItemsMetaRequest = async ({
c,
envelopeItems,
}: HandleEnvelopeItemsMetaRequestOptions) => {
const response = await Promise.all(
envelopeItems.map(async (item) => {
let pageMetadata = item.documentData.metadata;
// Runtime backfill if pageMetadata is missing.
if (!pageMetadata) {
const pdfBytes = await getFileServerSide({
type: item.documentData.type,
data: item.documentData.data,
});
const pdfPageMetadata: TDocumentDataMeta['pages'] = await extractAndStorePdfImages(
new Uint8Array(pdfBytes).buffer,
item.documentData.id,
);
pageMetadata = {
pages: pdfPageMetadata,
};
}
const pages = pageMetadata.pages ?? [];
return {
envelopeItemId: item.id,
documentDataId: item.documentData.id,
pages: pages.map((page) => ({
originalWidth: page.originalWidth,
originalHeight: page.originalHeight,
scale: page.scale,
scaledWidth: page.scaledWidth,
scaledHeight: page.scaledHeight,
})),
};
}),
);
return c.json({ envelopeItems: response } satisfies TGetEnvelopeItemsMetaResponse);
};
export default route;
+5
View File
@@ -12,6 +12,8 @@ import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
import { migrateDeletedAccountServiceAccount } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { migrateLegacyServiceAccount } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { env } from '@documenso/lib/utils/env';
import { logger } from '@documenso/lib/utils/logger';
@@ -144,4 +146,7 @@ if (env('NODE_ENV') !== 'development') {
// Start license client to verify license on startup.
void LicenseClient.start();
void migrateDeletedAccountServiceAccount();
void migrateLegacyServiceAccount();
export default app;
+51
View File
@@ -0,0 +1,51 @@
%PDF-1.7
%âãÏÓ
1 0 obj
<<
/Type /Pages
/Kids [4 0 R]
/Count 1
>>
endobj
2 0 obj
<<
/Type /Catalog
/Pages 1 0 R
>>
endobj
3 0 obj
<<
/Title (Untitled)
/Author (Unknown)
/Creator (@libpdf/core)
/Producer (@libpdf/core)
/CreationDate (D:20260211083727Z)
/ModDate (D:20260211083727Z)
>>
endobj
4 0 obj
<<
/Type /Page
/MediaBox [0 0 595 842]
/Resources <<
>>
/Parent 1 0 R
>>
endobj
xref
0 5
0000000000 65535 f
0000000015 00000 n
0000000072 00000 n
0000000121 00000 n
0000000290 00000 n
trailer
<<
/Size 5
/Root 2 0 R
/Info 3 0 R
/ID [<B051F100F1EED01A592FC6119F589603> <B051F100F1EED01A592FC6119F589603>]
>>
startxref
378
%%EOF
+51
View File
@@ -0,0 +1,51 @@
%PDF-1.7
%âãÏÓ
1 0 obj
<<
/Type /Pages
/Kids [4 0 R]
/Count 1
>>
endobj
2 0 obj
<<
/Type /Catalog
/Pages 1 0 R
>>
endobj
3 0 obj
<<
/Title (Untitled)
/Author (Unknown)
/Creator (@libpdf/core)
/Producer (@libpdf/core)
/CreationDate (D:20260211081729Z)
/ModDate (D:20260211081729Z)
>>
endobj
4 0 obj
<<
/Type /Page
/MediaBox [0 0 612 792]
/Resources <<
>>
/Parent 1 0 R
>>
endobj
xref
0 5
0000000000 65535 f
0000000015 00000 n
0000000072 00000 n
0000000121 00000 n
0000000290 00000 n
trailer
<<
/Size 5
/Root 2 0 R
/Info 3 0 R
/ID [<94A5FB5DCF5A94AD8C472C493420962C> <94A5FB5DCF5A94AD8C472C493420962C>]
>>
startxref
378
%%EOF
+51
View File
@@ -0,0 +1,51 @@
%PDF-1.7
%âãÏÓ
1 0 obj
<<
/Type /Pages
/Kids [4 0 R]
/Count 1
>>
endobj
2 0 obj
<<
/Type /Catalog
/Pages 1 0 R
>>
endobj
3 0 obj
<<
/Title (Untitled)
/Author (Unknown)
/Creator (@libpdf/core)
/Producer (@libpdf/core)
/CreationDate (D:20260211084535Z)
/ModDate (D:20260211084535Z)
>>
endobj
4 0 obj
<<
/Type /Page
/MediaBox [0 0 1224 792]
/Resources <<
>>
/Parent 1 0 R
>>
endobj
xref
0 5
0000000000 65535 f
0000000015 00000 n
0000000072 00000 n
0000000121 00000 n
0000000290 00000 n
trailer
<<
/Size 5
/Root 2 0 R
/Info 3 0 R
/ID [<694452F2208AC8E3DD2D2488544F9F0C> <694452F2208AC8E3DD2D2488544F9F0C>]
>>
startxref
379
%%EOF
+87 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.6.0",
"version": "2.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.6.0",
"version": "2.6.1",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -108,7 +108,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.6.0",
"version": "2.6.1",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -27365,6 +27365,24 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-cancellable-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
}
},
"node_modules/make-event-props": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
}
},
"node_modules/map-stream": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
@@ -27811,6 +27829,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-refs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -31840,6 +31875,44 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-pdf": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz",
"integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
"make-cancellable-promise": "^2.0.0",
"make-event-props": "^2.0.0",
"merge-refs": "^2.0.0",
"pdfjs-dist": "5.4.296",
"tiny-invariant": "^1.0.0",
"warning": "^4.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-pdf/node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-promise-suspense": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz",
@@ -36409,6 +36482,15 @@
"node": ">=20.0.0"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -37352,6 +37434,7 @@
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react-pdf": "^10.3.0",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
@@ -37509,6 +37592,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18",
"react-hook-form": "^7.66.1",
"react-pdf": "^10.3.0",
"react-rnd": "^10.5.2",
"remeda": "^2.32.0",
"tailwind-merge": "^1.14.0",
+1 -1
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.6.0",
"version": "2.6.1",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -1,7 +1,6 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -47,7 +46,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
@@ -55,7 +54,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
@@ -75,7 +74,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
@@ -101,7 +100,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
@@ -109,7 +108,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
@@ -129,7 +128,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
@@ -163,7 +162,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
@@ -171,7 +170,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
@@ -191,7 +190,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 500,
@@ -225,7 +224,7 @@ test.describe('AutoSave Fields Step', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
@@ -233,7 +232,7 @@ test.describe('AutoSave Fields Step', () => {
});
await page.getByRole('button', { name: 'Text' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
@@ -2,7 +2,6 @@ import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -29,7 +28,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
@@ -107,10 +106,10 @@ test.describe('AutoSave Subject Step', () => {
const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page);
// Toggle some email settings checkboxes (randomly - some checked, some unchecked)
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await page.getByText('Email the owner when a recipient signs').click();
await page.getByText("Email recipients when they're removed from a pending document").click();
await page.getByText('Email recipients when the document is completed', { exact: true }).click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -127,26 +126,30 @@ test.describe('AutoSave Subject Step', () => {
const emailSettings = retrievedDocumentData.documentMeta?.emailSettings;
await expect(page.getByText('Send recipient signed email')).toBeChecked({
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
checked: emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
await expect(
page.getByText("Email recipients when they're removed from a pending document"),
).toBeChecked({
checked: emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
await expect(
page.getByText('Email recipients when the document is completed', { exact: true }),
).toBeChecked({
checked: emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
checked: emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
checked: emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
checked: emailSettings?.ownerDocumentCompleted,
});
}).toPass();
@@ -162,10 +165,10 @@ test.describe('AutoSave Subject Step', () => {
await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject);
await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message);
await page.getByText('Send recipient signed email').click();
await page.getByText('Send recipient removed email').click();
await page.getByText('Send document completed email', { exact: true }).click();
await page.getByText('Send document deleted email').click();
await page.getByText('Email the owner when a recipient signs').click();
await page.getByText("Email recipients when they're removed from a pending document").click();
await page.getByText('Email recipients when the document is completed', { exact: true }).click();
await page.getByText('Email recipients when a pending document is deleted').click();
await triggerAutosave(page);
@@ -191,26 +194,30 @@ test.describe('AutoSave Subject Step', () => {
retrievedDocumentData.documentMeta?.message ?? '',
);
await expect(page.getByText('Send recipient signed email')).toBeChecked({
await expect(page.getByText('Email the owner when a recipient signs')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned,
});
await expect(page.getByText('Send recipient removed email')).toBeChecked({
await expect(
page.getByText("Email recipients when they're removed from a pending document"),
).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved,
});
await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({
await expect(
page.getByText('Email recipients when the document is completed', { exact: true }),
).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted,
});
await expect(page.getByText('Send document deleted email')).toBeChecked({
await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted,
});
await expect(page.getByText('Send recipient signing request email')).toBeChecked({
await expect(page.getByText('Email recipients with a signing request')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest,
});
await expect(page.getByText('Send document pending email')).toBeChecked({
await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending,
});
await expect(page.getByText('Send document completed email to the owner')).toBeChecked({
await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({
checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted,
});
}).toPass();
@@ -1,6 +1,5 @@
import { expect, test } from '@playwright/test';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -34,14 +33,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
// Step 3: Add fields
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
// Switch to second duplicate and add field
await page.getByText('Duplicate 2 (duplicate@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Continue to send
await page.getByRole('button', { name: 'Continue' }).click();
@@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient
// Add signature field for first duplicate recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click();
// Switch to second duplicate recipient and add their field
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click();
// Switch to unique recipient and add their field
await page.getByText('Unique Recipient (unique@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
// Continue to subject
await page.getByRole('button', { name: 'Continue' }).click();
@@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
// Save the document by going to subject
await page.getByRole('button', { name: 'Continue' }).click();
@@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
await page.getByText('Test Recipient Duplicate (test@example.com)').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Complete the flow
await page.getByRole('button', { name: 'Continue' }).click();
@@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add signature for first recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
// Add name field for second recipient
await page.getByRole('combobox').first().click();
await page.getByText('Approver Role (signer@example.com)').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Add date field for second recipient
await page.getByRole('button', { name: 'Date' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } });
await page.locator('canvas').click({ position: { x: 200, y: 150 } });
// If second recipient is still a SIGNER (role change wasn't available),
// add a signature field for them to pass validation
if (!secondRecipientIsApprover) {
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } });
await page.locator('canvas').click({ position: { x: 200, y: 200 } });
}
// Complete the document
@@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
// Add another field to the second duplicate
await page.getByRole('button', { name: 'Name' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } });
await page.locator('canvas').click({ position: { x: 250, y: 150 } });
// Save changes
await page.getByRole('button', { name: 'Continue' }).click();
@@ -9,7 +9,6 @@ import {
import { DateTime } from 'luxon';
import path from 'node:path';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
@@ -93,7 +92,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
@@ -101,7 +100,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
@@ -159,7 +158,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
@@ -167,7 +166,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
@@ -178,7 +177,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByText('User 2 (user2@example.com)').click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 500,
y: 100,
@@ -186,7 +185,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 500,
y: 200,
@@ -257,7 +256,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
@@ -265,7 +264,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
@@ -276,7 +275,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 500,
y: 100,
@@ -284,7 +283,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
});
await page.getByRole('button', { name: 'Email' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 500,
y: 200,
@@ -577,7 +576,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
}
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({
await page.locator('canvas').click({
position: {
x: 100,
y: 100 * i,
@@ -1,223 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
clickAddMyselfButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type TFieldFlowResult = {
externalId: string;
recipientEmail: string;
};
const TEST_FIELD_VALUES = {
embeddedRecipient: {
email: 'embedded-field-recipient@documenso.com',
name: 'Embedded Field Recipient',
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const setupRecipientsForFieldPlacement = async (surface: TEnvelopeEditorSurface) => {
if (surface.isEmbedded) {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email);
await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name);
return TEST_FIELD_VALUES.embeddedRecipient.email;
}
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
await clickAddMyselfButton(surface.root);
await expect(getRecipientEmailInputs(surface.root).first()).toHaveValue(surface.userEmail);
return surface.userEmail;
};
const placeFieldOnPdf = async (
root: Page,
fieldName: 'Signature' | 'Text',
position: { x: number; y: number },
) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
const runFieldFlow = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
const externalId = `e2e-fields-${nanoid()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
const recipientEmail = await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
await expect(surface.root.getByText('1 Field')).toBeVisible();
await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 });
await expect(surface.root.getByText('2 Fields')).toBeVisible();
await clickEnvelopeEditorStep(surface.root, 'upload');
await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
await clickEnvelopeEditorStep(surface.root, 'addFields');
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
await expect(surface.root.getByText('2 Fields')).toBeVisible();
return {
externalId,
recipientEmail,
};
};
const getFieldMetaType = (fieldMeta: unknown) => {
if (!isRecord(fieldMeta)) {
return null;
}
return typeof fieldMeta.type === 'string' ? fieldMeta.type : null;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const assertFieldsPersistedInDatabase = async ({
surface,
externalId,
recipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
recipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: {
createdAt: 'desc',
},
include: {
fields: true,
recipients: true,
},
});
const recipient = envelope.recipients.find(
(currentRecipient) => currentRecipient.email === recipientEmail,
);
expect(recipient).toBeDefined();
const fieldTypes = envelope.fields.map((field) => field.type).sort();
const expectedFieldTypes = [FieldType.SIGNATURE, FieldType.TEXT].sort();
expect(envelope.fields).toHaveLength(2);
expect(fieldTypes).toEqual(expectedFieldTypes);
expect(new Set(envelope.fields.map((field) => field.envelopeItemId)).size).toBe(1);
expect(envelope.fields.every((field) => field.recipientId === recipient?.id)).toBe(true);
const signatureField = envelope.fields.find((field) => field.type === FieldType.SIGNATURE);
const textField = envelope.fields.find((field) => field.type === FieldType.TEXT);
expect(getFieldMetaType(signatureField?.fieldMeta)).toBe('signature');
expect(getFieldMetaType(textField?.fieldMeta)).toBe('text');
};
test.describe('Envelope Editor V2 - Fields', () => {
test('documents/<id>: add and persist signature/text fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runFieldFlow(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('templates/<id>: add and persist signature/text fields', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runFieldFlow(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('/embed/v2/authoring/envelope/create DOCUMENT: add and persist signature/text fields', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-fields',
});
const result = await runFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: add and persist signature/text fields', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-fields',
});
const result = await runFieldFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertFieldsPersistedInDatabase({
surface,
...result,
});
});
});
@@ -1,182 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import {
type TEnvelopeEditorSurface,
getEnvelopeItemDragHandles,
getEnvelopeItemDropzoneInput,
getEnvelopeItemRemoveButtons,
getEnvelopeItemTitleInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
} from '../fixtures/envelope-editor';
test.use({
storageState: {
cookies: [],
origins: [],
},
});
type TestFilePayload = {
name: string;
mimeType: string;
buffer: Buffer;
};
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
const createPdfPayload = (name: string): TestFilePayload => ({
name,
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
const getCurrentTitles = async (root: Page) => {
const titleInputs = getEnvelopeItemTitleInputs(root);
const count = await titleInputs.count();
return await Promise.all(
Array.from({ length: count }, async (_, index) => await titleInputs.nth(index).inputValue()),
);
};
const uploadFiles = async (root: Page, files: TestFilePayload[]) => {
const input = getEnvelopeItemDropzoneInput(root);
await input.setInputFiles(files);
};
const dragEnvelopeItemByHandle = async ({
root,
sourceIndex,
targetIndex,
}: {
root: Page;
sourceIndex: number;
targetIndex: number;
}) => {
const sourceHandle = getEnvelopeItemDragHandles(root).nth(sourceIndex);
const targetHandle = getEnvelopeItemDragHandles(root).nth(targetIndex);
await expect(sourceHandle).toBeVisible();
await expect(targetHandle).toBeVisible();
const sourceBox = await sourceHandle.boundingBox();
const targetBox = await targetHandle.boundingBox();
if (!sourceBox || !targetBox) {
throw new Error('Could not resolve drag handle bounding boxes');
}
const sourceX = sourceBox.x + sourceBox.width / 2;
const sourceY = sourceBox.y + sourceBox.height / 2;
const targetX = targetBox.x + targetBox.width / 2;
const targetY = targetBox.y + targetBox.height / 2;
await root.mouse.move(sourceX, sourceY);
await root.mouse.down();
await root.mouse.move(targetX, targetY, { steps: 20 });
await root.mouse.up();
};
const runEnvelopeItemCrudFlow = async ({
root,
isEmbedded,
initialCount,
filesToUpload,
}: TEnvelopeEditorSurface & {
initialCount: number;
filesToUpload: TestFilePayload[];
}) => {
await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible();
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(initialCount);
await uploadFiles(root, filesToUpload);
const expectedCountAfterUpload = initialCount + filesToUpload.length;
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload);
await getEnvelopeItemTitleInputs(root).nth(0).fill('Envelope Item A');
await getEnvelopeItemTitleInputs(root).nth(1).fill('Envelope Item B');
await expect(getEnvelopeItemTitleInputs(root).nth(0)).toHaveValue('Envelope Item A');
await expect(getEnvelopeItemTitleInputs(root).nth(1)).toHaveValue('Envelope Item B');
await dragEnvelopeItemByHandle({
root,
sourceIndex: 0,
targetIndex: 1,
});
await expect
.poll(async () => await getCurrentTitles(root))
.toEqual(['Envelope Item B', 'Envelope Item A']);
await getEnvelopeItemRemoveButtons(root).first().click();
if (!isEmbedded) {
await root.getByRole('button', { name: 'Delete' }).click();
}
await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload - 1);
};
test.describe('Envelope Editor V2 - Envelope item CRUD', () => {
test('documents/<id>: add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
await runEnvelopeItemCrudFlow({
...surface,
initialCount: 1,
filesToUpload: [createPdfPayload('document-item-added.pdf')],
});
});
test('templates/<id>: add, remove, reorder and retitle items', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
await runEnvelopeItemCrudFlow({
...surface,
initialCount: 1,
filesToUpload: [createPdfPayload('template-item-added.pdf')],
});
});
test('/embed/v2/authoring/envelope/create DOCUMENT: add, remove, reorder and retitle items', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
});
await runEnvelopeItemCrudFlow({
...surface,
initialCount: 0,
filesToUpload: [
createPdfPayload('embedded-document-item-a.pdf'),
createPdfPayload('embedded-document-item-b.pdf'),
],
});
});
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: add, remove, reorder and retitle items', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-items',
});
await runEnvelopeItemCrudFlow({
...surface,
initialCount: 1,
filesToUpload: [createPdfPayload('embedded-template-item-updated.pdf')],
});
});
});
@@ -1,279 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
assertRecipientRole,
clickAddMyselfButton,
clickAddSignerButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
getRecipientNameInputs,
getRecipientRemoveButtons,
getSigningOrderInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
setRecipientEmail,
setRecipientName,
setRecipientRole,
setSigningOrderValue,
toggleAllowDictateSigners,
toggleSigningOrder,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type RecipientFlowResult = {
externalId: string;
expectedRecipientsBySigningOrder: Array<{
email: string;
name: string;
role: RecipientRole;
signingOrder: number;
}>;
removedRecipientEmail: string;
};
const TEST_RECIPIENT_VALUES = {
secondRecipient: {
email: 'recipient-two@example.com',
name: 'Recipient Two',
},
thirdRecipient: {
email: 'recipient-three@example.com',
name: 'Recipient Three',
},
embeddedPrimaryRecipient: {
email: 'embedded-primary@example.com',
name: 'Embedded Primary',
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
const navigateToAddFieldsAndBack = async (root: Page) => {
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.getByText('Selected Recipient')).toBeVisible();
await clickEnvelopeEditorStep(root, 'upload');
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
};
const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise<RecipientFlowResult> => {
const externalId = `e2e-recipients-${nanoid()}`;
await updateExternalId(surface, externalId);
let primaryRecipient = TEST_RECIPIENT_VALUES.embeddedPrimaryRecipient;
if (surface.isEmbedded) {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toHaveCount(0);
await setRecipientEmail(surface.root, 0, primaryRecipient.email);
await setRecipientName(surface.root, 0, primaryRecipient.name);
} else {
await expect(surface.root.getByRole('button', { name: 'Add Myself' })).toBeVisible();
await clickAddMyselfButton(surface.root);
primaryRecipient = {
email: surface.userEmail,
name: surface.userName,
};
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(surface.userEmail);
}
await clickAddSignerButton(surface.root);
await clickAddSignerButton(surface.root);
await setRecipientEmail(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.email);
await setRecipientName(surface.root, 1, TEST_RECIPIENT_VALUES.secondRecipient.name);
await setRecipientEmail(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.email);
await setRecipientName(surface.root, 2, TEST_RECIPIENT_VALUES.thirdRecipient.name);
await setRecipientRole(surface.root, 1, 'Needs to approve');
await setRecipientRole(surface.root, 2, 'Receives copy');
await getRecipientRemoveButtons(surface.root).nth(2).click();
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await toggleSigningOrder(surface.root, true);
await expect(getSigningOrderInputs(surface.root)).toHaveCount(2);
await setSigningOrderValue(surface.root, 0, 2);
await toggleAllowDictateSigners(surface.root, true);
await navigateToAddFieldsAndBack(surface.root);
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
await expect(getRecipientEmailInputs(surface.root).nth(0)).toHaveValue(
TEST_RECIPIENT_VALUES.secondRecipient.email,
);
await expect(getRecipientEmailInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.email);
await expect(getRecipientNameInputs(surface.root).nth(0)).toHaveValue(
TEST_RECIPIENT_VALUES.secondRecipient.name,
);
await expect(getRecipientNameInputs(surface.root).nth(1)).toHaveValue(primaryRecipient.name);
await assertRecipientRole(surface.root, 0, 'Needs to approve');
await assertRecipientRole(surface.root, 1, 'Needs to sign');
await expect(surface.root.locator('#signingOrder')).toHaveAttribute('aria-checked', 'true');
await expect(surface.root.locator('#allowDictateNextSigner')).toHaveAttribute(
'aria-checked',
'true',
);
await expect(getSigningOrderInputs(surface.root).nth(0)).toHaveValue('1');
await expect(getSigningOrderInputs(surface.root).nth(1)).toHaveValue('2');
return {
externalId,
removedRecipientEmail: TEST_RECIPIENT_VALUES.thirdRecipient.email,
expectedRecipientsBySigningOrder: [
{
email: TEST_RECIPIENT_VALUES.secondRecipient.email,
name: TEST_RECIPIENT_VALUES.secondRecipient.name,
role: RecipientRole.APPROVER,
signingOrder: 1,
},
{
email: primaryRecipient.email,
name: primaryRecipient.name,
role: RecipientRole.SIGNER,
signingOrder: 2,
},
],
};
};
const assertRecipientsPersistedInDatabase = async ({
surface,
externalId,
expectedRecipientsBySigningOrder,
removedRecipientEmail,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
expectedRecipientsBySigningOrder: RecipientFlowResult['expectedRecipientsBySigningOrder'];
removedRecipientEmail: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
documentMeta: true,
recipients: {
orderBy: {
signingOrder: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
expect(envelope.recipients).toHaveLength(expectedRecipientsBySigningOrder.length);
expect(envelope.documentMeta.signingOrder).toBe(DocumentSigningOrder.SEQUENTIAL);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(true);
expectedRecipientsBySigningOrder.forEach((expectedRecipient, index) => {
const recipient = envelope.recipients[index];
expect(recipient.email).toBe(expectedRecipient.email);
expect(recipient.name).toBe(expectedRecipient.name);
expect(recipient.role).toBe(expectedRecipient.role);
expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
});
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(
false,
);
};
test.describe('Envelope Editor V2 - Recipients', () => {
test('documents/<id>: add myself, CRUD, roles, signing order and dictate signers', async ({
page,
}) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runRecipientFlow(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
test('templates/<id>: add myself, CRUD, roles, signing order and dictate signers', async ({
page,
}) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runRecipientFlow(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
test('/embed/v2/authoring/envelope/create DOCUMENT: recipients settings persist after create', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-recipients',
});
await addEnvelopeItemPdf(surface.root, 'embedded-document-recipients.pdf');
const result = await runRecipientFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: recipients settings persist after update', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-recipients',
});
const result = await runRecipientFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertRecipientsPersistedInDatabase({
surface,
...result,
});
});
});
@@ -1,359 +0,0 @@
import { type Page, expect, test } from '@playwright/test';
import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
getEnvelopeEditorSettingsTrigger,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type SettingsFlowData = {
externalId: string;
isEmbedded: boolean;
};
const TEST_SETTINGS_VALUES = {
replyTo: 'e2e-settings@example.com',
redirectUrl: 'https://example.com/e2e-settings-complete',
subject: 'E2E settings subject',
message: 'E2E settings message',
language: 'French',
dateFormat: 'DD/MM/YYYY',
timezone: 'Europe/London',
distributionMethod: 'None',
accessAuth: 'Require account',
actionAuth: 'Require password',
visibility: 'Managers and above',
};
const DB_EXPECTED_VALUES = {
language: 'fr',
dateFormat: 'dd/MM/yyyy',
timezone: 'Europe/London',
distributionMethod: DocumentDistributionMethod.NONE,
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: ['PASSWORD'],
emailSettings: {
recipientSigned: false,
recipientSigningRequest: false,
recipientRemoved: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: false,
},
};
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const clickSettingsDialogHeader = async (root: Page) => {
await root.locator('[data-testid="envelope-editor-settings-dialog-header"]').click();
};
const getComboboxByLabel = (root: Page, label: string) =>
root
.locator(`label:has-text("${label}")`)
.locator('xpath=..')
.locator('[role="combobox"]')
.first();
const selectMultiSelectOption = async (
root: Page,
dataTestId: 'documentAccessSelectValue' | 'documentActionSelectValue',
optionLabel: string,
) => {
const select = root.locator(`[data-testid="${dataTestId}"]`);
await select.click();
await root.locator('[cmdk-item]').filter({ hasText: optionLabel }).first().click();
await clickSettingsDialogHeader(root);
};
const runSettingsFlow = async (
{ root }: TEnvelopeEditorSurface,
{ externalId, isEmbedded }: SettingsFlowData,
) => {
await openSettingsDialog(root);
await getComboboxByLabel(root, 'Language').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.language }).click();
await clickSettingsDialogHeader(root);
const signatureTypesCombobox = getComboboxByLabel(root, 'Allowed Signature Types');
await signatureTypesCombobox.click();
await root.getByRole('option', { name: 'Upload' }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Date Format').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Time Zone').click();
await root.locator('[cmdk-input]').last().fill(TEST_SETTINGS_VALUES.timezone);
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.timezone }).click();
await clickSettingsDialogHeader(root);
await root.locator('input[name="externalId"]').fill(externalId);
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
await clickSettingsDialogHeader(root);
await root.getByRole('button', { name: 'Email' }).click();
await root.locator('#recipientSigned').click();
await root.locator('#recipientSigningRequest').click();
await root.locator('#recipientRemoved').click();
await root.locator('#documentPending').click();
await root.locator('#documentCompleted').click();
await root.locator('#documentDeleted').click();
await root.locator('#ownerDocumentCompleted').click();
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
await root.getByRole('button', { name: 'Security' }).click();
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
const actionAuthSelect = root.locator('[data-testid="documentActionSelectValue"]');
const hasActionAuthSelect = (await actionAuthSelect.count()) > 0;
if (hasActionAuthSelect) {
await selectMultiSelectOption(
root,
'documentActionSelectValue',
TEST_SETTINGS_VALUES.actionAuth,
);
}
await root.locator('[data-testid="documentVisibilitySelectValue"]').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.visibility }).click();
await clickSettingsDialogHeader(root);
await root.getByRole('button', { name: 'Update' }).click();
if (!isEmbedded) {
await expectToastTextToBeVisible(root, 'Envelope updated');
}
await openSettingsDialog(root);
await expect(root.locator('input[name="externalId"]')).toHaveValue(externalId);
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(
TEST_SETTINGS_VALUES.redirectUrl,
);
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(
TEST_SETTINGS_VALUES.dateFormat,
);
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.distributionMethod,
);
await root.getByRole('button', { name: 'Email' }).click();
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('input[name="meta.emailReplyTo"]')).toHaveValue(
TEST_SETTINGS_VALUES.replyTo,
);
await expect(root.locator('input[name="meta.subject"]')).toHaveValue(
TEST_SETTINGS_VALUES.subject,
);
await expect(root.locator('textarea[name="meta.message"]')).toHaveValue(
TEST_SETTINGS_VALUES.message,
);
await root.getByRole('button', { name: 'Security' }).click();
await expect(root.locator('[data-testid="documentAccessSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.accessAuth,
);
if (hasActionAuthSelect) {
await expect(root.locator('[data-testid="documentActionSelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.actionAuth,
);
}
await expect(root.locator('[data-testid="documentVisibilitySelectValue"]')).toContainText(
TEST_SETTINGS_VALUES.visibility,
);
await root.getByRole('button', { name: 'Update' }).click();
if (!isEmbedded) {
await expectToastTextToBeVisible(root, 'Envelope updated');
}
return {
hasActionAuthSelect,
};
};
const assertEnvelopeSettingsPersistedInDatabase = async ({
externalId,
surface,
hasActionAuthSelect,
}: {
externalId: string;
surface: TEnvelopeEditorSurface;
hasActionAuthSelect: boolean;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: {
documentMeta: true,
},
});
expect(envelope.externalId).toBe(externalId);
expect(envelope.visibility).toBe(DB_EXPECTED_VALUES.visibility);
expect(envelope.documentMeta.language).toBe(DB_EXPECTED_VALUES.language);
expect(envelope.documentMeta.dateFormat).toBe(DB_EXPECTED_VALUES.dateFormat);
expect(envelope.documentMeta.timezone).toBe(DB_EXPECTED_VALUES.timezone);
expect(envelope.documentMeta.distributionMethod).toBe(DB_EXPECTED_VALUES.distributionMethod);
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
expect(envelope.documentMeta.message).toBe(TEST_SETTINGS_VALUES.message);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
const authOptions = parseAuthOptions(envelope.authOptions);
expect(authOptions.globalAccessAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalAccessAuth);
if (hasActionAuthSelect) {
expect(authOptions.globalActionAuth ?? []).toEqual(DB_EXPECTED_VALUES.globalActionAuth);
}
};
const parseAuthOptions = (
authOptions: unknown,
): { globalAccessAuth: string[]; globalActionAuth: string[] } => {
if (!isRecord(authOptions)) {
return {
globalAccessAuth: [],
globalActionAuth: [],
};
}
return {
globalAccessAuth: Array.isArray(authOptions.globalAccessAuth)
? authOptions.globalAccessAuth.filter((entry): entry is string => typeof entry === 'string')
: [],
globalActionAuth: Array.isArray(authOptions.globalActionAuth)
? authOptions.globalActionAuth.filter((entry): entry is string => typeof entry === 'string')
: [],
};
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
test.describe('Envelope Editor V2 - Envelope settings dialog', () => {
test('documents/<id>: update and persist settings', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: false,
});
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
});
});
test('templates/<id>: update and persist settings', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: false,
});
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
});
});
test('/embed/v2/authoring/envelope/create DOCUMENT: update and persist settings', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-settings',
});
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: true,
});
await persistEmbeddedEnvelope(surface);
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
});
});
test('/embed/v2/authoring/envelope/edit/<id> TEMPLATE: update and persist settings', async ({
page,
}) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-settings',
});
const externalId = `e2e-settings-${nanoid()}`;
const { hasActionAuthSelect } = await runSettingsFlow(surface, {
externalId,
isEmbedded: true,
});
await persistEmbeddedEnvelope(surface);
await assertEnvelopeSettingsPersistedInDatabase({
externalId,
surface,
hasActionAuthSelect,
});
});
});
@@ -0,0 +1,218 @@
import { type APIRequestContext, type Page, expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
import { RecipientRole } from '../../../prisma/generated/types';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '../../../trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const signAndVerifyPageDimensions = async ({
page,
request,
pdfFile,
identifier,
title,
expectedWidth,
expectedHeight,
}: {
page: Page;
request: APIRequestContext;
pdfFile: string;
identifier: string;
title: string;
expectedWidth: number;
expectedHeight: number;
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const pdfBuffer = fs.readFileSync(path.join(__dirname, `../../../../assets/${pdfFile}`));
const formData = new FormData();
const createEnvelopePayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title,
recipients: [
{
email: user.email,
name: user.name || '',
role: RecipientRole.SIGNER,
fields: [
{
identifier,
type: FieldType.SIGNATURE,
fieldMeta: { type: 'signature' },
page: 1,
positionX: 10,
positionY: 10,
width: 40,
height: 10,
},
],
},
],
};
formData.append('payload', JSON.stringify(createEnvelopePayload));
formData.append('files', new File([pdfBuffer], identifier, { type: 'application/pdf' }));
const createResponse = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createResponse.ok()).toBeTruthy();
const { id: envelopeId }: TCreateEnvelopeResponse = await createResponse.json();
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: { recipients: true },
});
const distributeResponse = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId: envelope.id } satisfies TDistributeEnvelopeRequest,
});
expect(distributeResponse.ok()).toBeTruthy();
// Pre-insert all fields via Prisma so we can skip the UI field interaction.
const fields = await prisma.field.findMany({
where: { envelopeId: envelope.id, inserted: false },
});
for (const field of fields) {
await prisma.field.update({
where: { id: field.id },
data: {
inserted: true,
signature: {
create: {
recipientId: envelope.recipients[0].id,
typedSignature: 'Test Signature',
},
},
},
});
}
const recipientToken = envelope.recipients[0].token;
const signUrl = `/sign/${recipientToken}`;
await apiSignin({
page,
email: user.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await expect(async () => {
const { status } = await prisma.envelope.findFirstOrThrow({
where: { id: envelope.id },
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass({ timeout: 10000 });
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: envelope.id },
include: {
envelopeItems: {
orderBy: { order: 'asc' },
include: { documentData: true },
},
},
});
for (const item of completedEnvelope.envelopeItems) {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: item,
token: recipientToken,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(pdfData) });
const pdf = await loadingTask.promise;
expect(pdf.numPages).toBeGreaterThan(1);
for (let i = 1; i <= pdf.numPages; i++) {
const pdfPage = await pdf.getPage(i);
const viewport = pdfPage.getViewport({ scale: 1 });
expect(Math.round(viewport.width)).toBe(expectedWidth);
expect(Math.round(viewport.height)).toBe(expectedHeight);
}
}
};
test('cert and audit log pages match letter page dimensions', async ({ page, request }) => {
await signAndVerifyPageDimensions({
page,
request,
pdfFile: 'letter-size.pdf',
identifier: 'letter-doc',
title: 'Letter Size Dimension Test',
expectedWidth: 612,
expectedHeight: 792,
});
});
test('cert and audit log pages match A4 page dimensions', async ({ page, request }) => {
await signAndVerifyPageDimensions({
page,
request,
pdfFile: 'a4-size.pdf',
identifier: 'a4-doc',
title: 'A4 Size Dimension Test',
expectedWidth: 595,
expectedHeight: 842,
});
});
test('cert and audit log pages match tabloid landscape page dimensions', async ({
page,
request,
}) => {
await signAndVerifyPageDimensions({
page,
request,
pdfFile: 'tabloid-landscape.pdf',
identifier: 'tabloid-doc',
title: 'Tabloid Landscape Dimension Test',
expectedWidth: 1224,
expectedHeight: 792,
});
});
@@ -1,12 +1,13 @@
import { createCanvas } from '@napi-rs/canvas';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import { pdfToImages } from '@documenso/lib/server-only/ai/pdf-to-images';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
@@ -297,18 +298,34 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
*
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('download envelope images', async ({ page }) => {
test.skip('download envelope images', async ({ page, request }) => {
const { user, team } = await seedUser();
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const envelope = await seedAlignmentTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
status: DocumentStatus.PENDING,
status: DocumentStatus.DRAFT,
});
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${apiToken}` },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
@@ -377,14 +394,39 @@ test.skip('download envelope images', async ({ page }) => {
});
async function renderPdfToImage(pdfBytes: Uint8Array) {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
// Increase for higher resolution
const scale = 4;
return (await pdfToImages(pdfBytes, { scale, imageFormat: 'png' })).map((image) => ({
image: image.image,
width: Math.floor(image.scaledWidth),
height: Math.floor(image.scaledHeight),
}));
return await Promise.all(
Array.from({ length: pdf.numPages }, async (_, index) => {
const page = await pdf.getPage(index + 1);
const viewport = page.getViewport({ scale });
const canvas = createCanvas(viewport.width, viewport.height);
const canvasContext = canvas.getContext('2d');
canvasContext.imageSmoothingEnabled = false;
await page.render({
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvas,
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvasContext,
viewport,
}).promise;
return {
image: await canvas.encode('png'),
// Rounded down because the certificate page somehow gives dimensions with decimals
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
};
}),
);
}
type CompareSignedPdfWithImagesOptions = {
@@ -1,429 +0,0 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-editor';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from './authentication';
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
export type TEnvelopeEditorSurface = {
root: Page;
isEmbedded: boolean;
envelopeId?: string;
envelopeType: TEnvelopeEditorType;
userId: number;
userEmail: string;
userName: string;
teamId: number;
};
export type TEnvelopeEditorType = 'DOCUMENT' | 'TEMPLATE';
type TEmbeddedHashCommonOptions = {
externalId?: string;
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
css?: string;
cssVars?: Record<string, string>;
darkModeDisabled?: boolean;
};
const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
const encodedPayload = encodeURIComponent(JSON.stringify(options));
if (typeof btoa === 'function') {
return btoa(encodedPayload);
}
return Buffer.from(encodedPayload, 'utf8').toString('base64');
};
export const createEmbeddedEnvelopeCreateHash = ({
envelopeType,
externalId,
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
}: { envelopeType: TEnvelopeEditorType } & TEmbeddedHashCommonOptions) => {
return encodeEmbeddedOptions({
externalId,
type: envelopeType,
features,
css,
cssVars,
darkModeDisabled,
});
};
export const createEmbeddedEnvelopeEditHash = ({
externalId,
features = DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
}: TEmbeddedHashCommonOptions) => {
return encodeEmbeddedOptions({
externalId,
features,
css,
cssVars,
darkModeDisabled,
});
};
export const openDocumentEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id, {
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`,
});
return {
root: page,
isEmbedded: false,
envelopeId: document.id,
envelopeType: 'DOCUMENT',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
export const openTemplateEnvelopeEditor = async (page: Page): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: `E2E Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit?step=uploadAndRecipients`,
});
return {
root: page,
isEmbedded: false,
envelopeId: template.id,
envelopeType: 'TEMPLATE',
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
type OpenEmbeddedEnvelopeEditorOptions = {
envelopeType: TEnvelopeEditorType;
mode?: 'create' | 'edit';
tokenNamePrefix?: string;
externalId?: string;
features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG;
css?: string;
cssVars?: Record<string, string>;
darkModeDisabled?: boolean;
};
export const openEmbeddedEnvelopeEditor = async (
page: Page,
{
envelopeType,
mode = 'create',
tokenNamePrefix = 'e2e-embed',
externalId,
features,
css,
cssVars,
darkModeDisabled,
}: OpenEmbeddedEnvelopeEditorOptions,
): Promise<TEnvelopeEditorSurface> => {
const { user, team } = await seedUser();
const envelopeToEdit =
mode === 'edit'
? envelopeType === 'DOCUMENT'
? await seedBlankDocument(user, team.id, {
internalVersion: 2,
})
: await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: `E2E Template ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
},
})
: null;
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: `${tokenNamePrefix}-${envelopeType.toLowerCase()}`,
expiresIn: null,
});
const embeddedToken = await resolveEmbeddingToken(
page,
token,
envelopeToEdit ? `envelopeId:${envelopeToEdit.id}` : undefined,
);
if (envelopeToEdit) {
const hash = createEmbeddedEnvelopeEditHash({
externalId,
features: features ?? DEFAULT_EMBEDDED_EDITOR_CONFIG,
css,
cssVars,
darkModeDisabled,
});
await page.goto(
`/embed/v2/authoring/envelope/edit/${envelopeToEdit.id}?token=${encodeURIComponent(embeddedToken)}#${hash}`,
);
} else {
const hash = createEmbeddedEnvelopeCreateHash({
envelopeType,
externalId,
features,
css,
cssVars,
darkModeDisabled,
});
await page.goto(
`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`,
);
}
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
return {
root: page,
isEmbedded: true,
envelopeId: envelopeToEdit?.id,
envelopeType,
userId: user.id,
userEmail: user.email,
userName: user.name ?? '',
teamId: team.id,
};
};
export const getEnvelopeEditorSettingsTrigger = (root: Page) =>
root.locator('button[title="Settings"]');
export const getEnvelopeItemTitleInputs = (root: Page) =>
root.locator('[data-testid^="envelope-item-title-input-"]');
export const getEnvelopeItemDragHandles = (root: Page) =>
root.locator('[data-testid^="envelope-item-drag-handle-"]');
export const getEnvelopeItemRemoveButtons = (root: Page) =>
root.locator('[data-testid^="envelope-item-remove-button-"]');
export const getEnvelopeItemDropzoneInput = (root: Page) =>
root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]');
export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelope-item.pdf') => {
await getEnvelopeItemDropzoneInput(root).setInputFiles({
name: fileName,
mimeType: 'application/pdf',
buffer: examplePdfBuffer,
});
};
export const getRecipientEmailInputs = (root: Page) =>
root.locator('[data-testid="signer-email-input"]');
export const getRecipientNameInputs = (root: Page) =>
root.locator('input[placeholder^="Recipient "]');
export const getRecipientRows = (root: Page) =>
root.locator('[data-testid="signer-email-input"]').locator('xpath=ancestor::fieldset[1]');
export const getRecipientRemoveButtons = (root: Page) =>
root.locator('[data-testid="remove-signer-button"]');
export const getSigningOrderInputs = (root: Page) =>
root.locator('[data-testid="signing-order-input"]');
export const clickEnvelopeEditorStep = async (
root: Page,
stepId: 'upload' | 'addFields' | 'preview',
) => {
await root.waitForTimeout(200);
await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click();
};
export const clickAddMyselfButton = async (root: Page) => {
await root.getByRole('button', { name: 'Add Myself' }).click();
};
export const clickAddSignerButton = async (root: Page) => {
await root.getByRole('button', { name: 'Add Signer' }).click();
};
export const setRecipientEmail = async (root: Page, index: number, email: string) => {
await getRecipientEmailInputs(root).nth(index).fill(email);
};
export const setRecipientName = async (root: Page, index: number, name: string) => {
await getRecipientNameInputs(root).nth(index).fill(name);
};
export const setRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
) => {
const row = getRecipientRows(root).nth(index);
await row.locator('button[role="combobox"]').first().click();
await root.getByRole('option', { name: roleLabel }).click();
};
export const assertRecipientRole = async (
root: Page,
index: number,
roleLabel:
| 'Needs to sign'
| 'Needs to approve'
| 'Needs to view'
| 'Receives copy'
| 'Can prepare',
) => {
const row = getRecipientRows(root).nth(index);
const roleValueByLabel: Record<typeof roleLabel, string> = {
'Needs to sign': 'SIGNER',
'Needs to approve': 'APPROVER',
'Needs to view': 'VIEWER',
'Receives copy': 'CC',
'Can prepare': 'ASSISTANT',
};
await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute(
'title',
roleValueByLabel[roleLabel],
);
};
export const toggleSigningOrder = async (root: Page, enabled: boolean) => {
const checkbox = root.locator('#signingOrder');
const currentState = await checkbox.getAttribute('aria-checked');
const isEnabled = currentState === 'true';
if (isEnabled !== enabled) {
await checkbox.click();
}
};
export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => {
const checkbox = root.locator('#allowDictateNextSigner');
const currentState = await checkbox.getAttribute('aria-checked');
const isEnabled = currentState === 'true';
if (isEnabled !== enabled) {
await checkbox.click();
}
};
export const setSigningOrderValue = async (root: Page, index: number, value: number) => {
const input = getSigningOrderInputs(root).nth(index);
await input.fill(value.toString());
await input.blur();
};
export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => {
if (!surface.isEmbedded) {
return;
}
const isUpdateFlow =
(await surface.root.getByRole('button', { name: 'Update Document' }).count()) > 0 ||
(await surface.root.getByRole('button', { name: 'Update Template' }).count()) > 0;
const actionButtonName = isUpdateFlow
? surface.envelopeType === 'DOCUMENT'
? 'Update Document'
: 'Update Template'
: surface.envelopeType === 'DOCUMENT'
? 'Create Document'
: 'Create Template';
await surface.root.getByRole('button', { name: actionButtonName }).click();
const completionHeading = isUpdateFlow
? surface.envelopeType === 'DOCUMENT'
? 'Document Updated'
: 'Template Updated'
: surface.envelopeType === 'DOCUMENT'
? 'Document Created'
: 'Template Created';
await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible();
};
const resolveEmbeddingToken = async (
page: Page,
inputToken: string,
scope?: string,
): Promise<string> => {
if (!inputToken.startsWith('api_')) {
return inputToken;
}
const response = await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, {
headers: {
Authorization: `Bearer ${inputToken}`,
'Content-Type': 'application/json',
},
data: scope ? { scope } : {},
});
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to exchange API token (${response.status()}): ${text}`);
}
const data: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('token' in data)) {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
const token = data.token;
if (typeof token !== 'string' || token.length === 0) {
throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`);
}
return token;
};
@@ -205,9 +205,13 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Reply to email' }).fill('organisation@documenso.com');
// Update email document settings by enabling/disabling some checkboxes
await page.getByRole('checkbox', { name: 'Send recipient signed email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document pending email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document deleted email' }).uncheck();
await page.getByRole('checkbox', { name: 'Email the owner when a recipient signs' }).uncheck();
await page
.getByRole('checkbox', { name: 'Email the signer if the document is still pending' })
.uncheck();
await page
.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' })
.uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
@@ -240,12 +244,14 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Override organisation settings' }).click();
// Update some email settings
await page.getByRole('checkbox', { name: 'Send recipient signing request email' }).uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email', exact: true })
.getByRole('checkbox', { name: 'Email recipients with a signing request' })
.uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email to the owner' })
.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true })
.uncheck();
await page
.getByRole('checkbox', { name: 'Email the owner when the document is completed' })
.uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
@@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: {
// Step 3: Add fields for each recipient instance
// Add signature field for first instance
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
// Switch to second instance and add their field
await page.getByRole('combobox').first().click();
await page.getByText('Second Instance').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
// Switch to different recipient and add their fields
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } });
await page.locator('canvas').click({ position: { x: 300, y: 100 } });
await page.getByRole('button', { name: 'Name' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } });
await page.locator('canvas').click({ position: { x: 300, y: 150 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
// Add fields for each recipient
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } });
await page.locator('canvas').click({ position: { x: 100, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Duplicate Recipient 2').first().click();
await page.getByRole('button', { name: 'Date' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } });
await page.locator('canvas').click({ position: { x: 200, y: 100 } });
await page.getByRole('combobox').first().click();
await page.getByText('Different Recipient').first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } });
await page.locator('canvas').click({ position: { x: 100, y: 200 } });
// Save template
await page.getByRole('button', { name: 'Save Template' }).click();
@@ -272,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'First Instance' }).first().click();
await page.getByRole('button', { name: 'Name' }).click();
await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } });
await page.locator('canvas').click({ position: { x: 100, y: 300 } });
await page.waitForTimeout(2500);

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