Compare commits

..

6 Commits

Author SHA1 Message Date
Lucas Smith 36c10d1a92 v2.10.1 2026-05-05 21:02:28 +10:00
Ephraim Duncan 8c0e029b1b feat: add pending signed PDF downloads (#2730) 2026-05-05 17:25:24 +10:00
David Nguyen f10d3284ba feat: remove default personal orgs from custom sso (#2741) 2026-05-05 14:50:07 +10:00
David Nguyen 6a6ef8d2ad feat: allow add myself feature for embeds (#2754) 2026-05-04 15:05:13 +10:00
Lucas Smith 690491c3b1 fix: prevent 2fa users from being flagged as bots (#2748) 2026-05-04 12:45:43 +10:00
Lucas Smith 6243a514af fix: csp frame-ancestors on signing routes 2026-05-02 09:55:51 +10:00
35 changed files with 1013 additions and 158 deletions
@@ -0,0 +1,94 @@
---
date: 2026-04-22
title: Partial Signed Pdf Download
---
## Summary
Let team members fetch a PDF with all currently-inserted fields burned in while the envelope is still in `PENDING` status. Today the only available bytes for a pending envelope are the original (no fields) - the sealed PDF only materialises after the last recipient signs and the `seal-document` job runs.
Exposed in two places:
- v2 API: `GET /api/v2/envelope/item/{envelopeItemId}/download?version=pending` (API-token auth)
- UI: a `Partial` button in the existing `EnvelopeDownloadDialog`, alongside `Original`. Replaces the `Signed` slot when the envelope is `PENDING`. Backed by the existing session-authed file route `GET /api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending`.
## Scope
- v2 API only (no v1).
- `internalVersion === 2` envelopes only. Legacy v1 returns 400 `ENVELOPE_LEGACY`.
- Team-side / owner only. No recipient-token download path - recipients have the in-app overlay viewer for verification, and a downloadable half-signed PDF is a leak vector for partially-executed contracts. Enforced both at the server (the recipient-token file route does not accept `pending`) and at the UI (the dialog hides the Partial button when a recipient token is set).
- No PKI signature, no certificate page, no audit log appendix - the response is explicitly not a final executed document.
- No watermark or banner text. The filename suffix (`_pending.pdf`), the `Cache-Control: no-store, private` header, and the absence of a PKI signature are sufficient to signal draft status.
## Behaviour
API response matrix (both `/api/v2/envelope/item/{id}/download?version=pending` and the UI-facing `/api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending`):
| Envelope status | Response |
|---|---|
| `PENDING` (v2) | 200, PDF with currently-inserted fields burned in |
| `PENDING` (v1) | 400 `ENVELOPE_LEGACY` |
| `DRAFT` | 400 `ENVELOPE_DRAFT` |
| `COMPLETED` | 400 `ENVELOPE_COMPLETED` |
| `REJECTED` | 400 `ENVELOPE_REJECTED` |
All v1-vs-v2 / status-mismatch errors are 4xx so callers can cleanly separate them from real server failures (5xx). Specifically v1 PENDING returns 400 not 501: 5xx is reserved for actual server problems, while "this envelope can't satisfy this request shape" is a client-addressable condition.
Filename: `{title}_pending.pdf`.
ETag is content-addressed over `sha256(envelope.status + sorted((field.id, field.customText, field.signature?.id, field.signature?.created) for inserted===true fields))`. Returns 304 on `If-None-Match` match.
No persistent caching. Generated on-demand per request when ETag misses.
Error response shape (envelope item v2 download route and the team-side file route): preserves the existing `{ error: <message> }` field for backwards compatibility and adds `code: <APP_ERROR_CODE>` as a new field for callers that want to branch on it. The document download route (`/document/{documentId}/download`) is untouched.
## UI
`apps/remix/app/components/dialogs/envelope-download-dialog.tsx`:
- The dialog shows `Original` plus one of:
- `Signed` when status is `COMPLETED` (existing behaviour)
- `Partial` when status is `PENDING`, there is no recipient token, and the envelope is not legacy (`!isLegacy`)
- nothing otherwise
- New optional prop `isLegacy?: boolean`. Only consulted to gate the `Partial` button, so callers whose status can never be `PENDING` (DRAFT/COMPLETED/REJECTED hardcoded, or `isComplete: true` matchers) and callers that always set a recipient token can omit it. Three call sites pass it (`isLegacy={envelope.internalVersion === 1}`): `documents-table-action-dropdown.tsx`, `envelope-editor.tsx`, `document-page-view-dropdown.tsx`. The other eight callers were left alone.
Trade-off: a future team-side dialog usage where status could be PENDING but the dev forgets `isLegacy` will silently not render the Partial button. The status gate prevents an actively broken click; missing button is discoverable in testing. Required-prop alternative was rejected because eight of eleven call sites would carry a meaningless value.
## Files
Server:
- `apps/remix/server/api/download/download.types.ts` - added `'pending'` to the `version` enum; split the validator into `param` (envelopeItemId) + `query` (version). The original wiring as a path-param validator was a pre-existing bug: requests like `?version=original` were silently returning the signed PDF since `version` actually arrives as a query string. Fixed as a side effect.
- `packages/trpc/server/envelope-router/download-envelope-item.types.ts` - mirrored the enum change in the OpenAPI schema.
- `apps/remix/server/api/download/download.ts` - the envelope item v2 route now fetches envelope recipients alongside the envelope, branches on `version` when calling the helper, and emits AppError responses as `{ error, code }` consistently across all status codes.
- `apps/remix/server/api/files/files.types.ts` - added `'pending'` to the team-side download schema only. The recipient-token download schema is untouched, so `/api/files/token/.../download/pending` is rejected by the schema validator.
- `apps/remix/server/api/files/files.ts` - the team-side download handler fetches envelope recipients and dispatches the `pending` branch through the same `handleEnvelopeItemFileRequest` helper. Wrapped in a try/catch that returns `{ error, code }` for AppErrors.
- `apps/remix/server/api/files/files.helpers.ts` - `handleEnvelopeItemFileRequest` is now a single entry point taking a discriminated-union options type. The static-file flow (`signed`/`original`) and the on-demand pending flow are private helpers in the same module.
- `packages/lib/server-only/pdf/generate-partial-signed-pdf.ts` (new) - small orchestrator that loads the original PDF, groups inserted fields by page, calls the existing `insertFieldInPDFV2` overlay helper for each page, flattens, and saves.
- `packages/lib/errors/app-error.ts` - added `ENVELOPE_DRAFT`, `ENVELOPE_COMPLETED`, `ENVELOPE_REJECTED`, `ENVELOPE_LEGACY` codes, all mapped to 400. The legacy-envelope case deliberately returns 4xx rather than 501 to keep "this resource can't satisfy this operation" distinct from real 5xx server failures in caller logs/metrics.
Client:
- `packages/lib/utils/envelope-download.ts` - `EnvelopeItemPdfUrlOptions` download variant now allows `'pending'` as a version. The recipient-token URL builder will produce a URL the server rejects, but the dialog gates on no-token at the call site.
- `packages/lib/client-only/download-pdf.ts` - `DocumentVersion` extended; filename suffix logic moved into a small switch (`_signed.pdf`, `_pending.pdf`, `.pdf`).
- `apps/remix/app/components/dialogs/envelope-download-dialog.tsx` - secondary download derivation with the new `Partial` branch, optional `isLegacy` prop.
- `apps/remix/app/components/tables/documents-table-action-dropdown.tsx`, `apps/remix/app/components/general/envelope-editor/envelope-editor.tsx`, `apps/remix/app/components/general/document/document-page-view-dropdown.tsx` - pass `isLegacy={envelope.internalVersion === 1}` (or `row.internalVersion === 1`) to the dialog.
## Verification
1. E2E (`packages/app-tests/e2e/api/v2/partial-signed-pdf-download.spec.ts`):
- Pending envelope, recipient 1 signs, API token download with `?version=pending` returns 200 + PDF; subsequent call with `If-None-Match: <etag>` returns 304; after recipient 2 completes the envelope flips to `COMPLETED` and the same call returns 400 `ENVELOPE_COMPLETED`; `?version=signed` then succeeds.
- Draft envelope returns 400 `ENVELOPE_DRAFT`.
- `internalVersion === 1` pending envelope returns 400 `ENVELOPE_LEGACY`.
2. `npx tsc --noEmit -p apps/remix/tsconfig.json` and `npm run lint`.
3. Manual: open the Documents table or envelope editor on a PENDING envelope (v2), open the download dialog, confirm `Partial` appears alongside `Original` and produces a `_pending.pdf` with current fields burned in. Same dialog on a COMPLETED envelope shows `Signed`. Same dialog on a v1 PENDING envelope shows neither (status gate would show Partial, but the `isLegacy` flag filters it out).
## Out of Scope / Follow-ups
- Recipient-token download path (API and UI) - decided against. Revisit if there is concrete demand and a story for limiting the leak vector.
- v1 API parity / v1 partial rendering - not building. Implementing partial for v1 would require porting `legacy_insertFieldInPDF` / `insertFieldInPDFV1` into a partial-only flow, which is code with no long-term home as v1 is being phased out.
- Document download route (`/document/{documentId}/download`) - untouched. Same error shape and validator wiring as before. Consider normalising to the same `{ error, code }` shape in a follow-up if any caller wants to branch on `code` from that route.
- Persistent caching layer / job-queue generation - revisit if p95 latency on large PDFs becomes an issue.
- Specific toast for `ENVELOPE_LEGACY` in the dialog - currently the catch-all "Something went wrong" handles it. Worth a polish if v1 PENDING envelopes are common in your data and we see complaints. (Note: with the `isLegacy` gate at the UI, the error is unreachable from the dialog itself; the API can still surface it for direct callers.)
@@ -102,17 +102,18 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
### All V2 Authoring Components
| Prop | Type | Required | Description |
| ------------------ | --------- | -------- | -------------------------------------------------------- |
| `presignToken` | `string` | Yes | Authentication token from your backend |
| `externalId` | `string` | No | Your reference ID to link with the envelope |
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
| `css` | `string` | No | Custom CSS string (Platform Plan) |
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
| `className` | `string` | No | CSS class for the iframe |
| `features` | `object` | No | Feature toggles for the authoring experience |
| Prop | Type | Required | Description |
| ---------------- | --------- | -------- | -------------------------------------------------------- |
| `presignToken` | `string` | Yes | Authentication token from your backend |
| `externalId` | `string` | No | Your reference ID to link with the envelope |
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
| `css` | `string` | No | Custom CSS string (Platform Plan) |
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
| `className` | `string` | No | CSS class for the iframe |
| `user` | `object` | No | Current user info. When provided, enables the "Add Myself" button in the recipients list. Object with optional `email` and `name` fields |
| `features` | `object` | No | Feature toggles for the authoring experience |
### Create Component Only
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -24,6 +24,19 @@ type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' |
type EnvelopeDownloadDialogProps = {
envelopeId: string;
envelopeStatus: DocumentStatus;
/**
* Whether the envelope is a legacy (v1) envelope. Only consulted to gate the
* partial-download variant: legacy envelopes use a different field-rendering
* pipeline that the partial PDF helper does not implement, so the Partial
* button is hidden for them.
*
* Optional: omit it on call sites where the status can never be PENDING (DRAFT,
* COMPLETED, REJECTED) or when a recipient token is set, since the Partial button
* is also gated on those. Pass it from team-side call sites that can render the
* dialog for a PENDING envelope.
*/
isLegacy?: boolean;
envelopeItems?: EnvelopeItemToDownload[];
/**
@@ -38,6 +51,7 @@ type EnvelopeDownloadDialogProps = {
export const EnvelopeDownloadDialog = ({
envelopeId,
envelopeStatus,
isLegacy,
envelopeItems: initialEnvelopeItems,
token,
trigger,
@@ -51,8 +65,36 @@ export const EnvelopeDownloadDialog = ({
[envelopeItemIdAndVersion: string]: boolean;
}>({});
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
`${envelopeItemId}-${version}`;
const generateDownloadKey = (
envelopeItemId: string,
version: 'original' | 'signed' | 'pending',
) => `${envelopeItemId}-${version}`;
// The dialog shows the original document alongside one of:
// - "Signed" (when the envelope is COMPLETED)
// - "Partial" (when the envelope is PENDING, not legacy, and we are on the
// team/owner side; recipients are intentionally not offered this since the
// partial PDF carries no PKI signature and would create a leak vector for
// half-executed contracts; legacy envelopes use a different rendering
// pipeline that the partial-download helper does not implement)
// - nothing (DRAFT, REJECTED, PENDING with recipient token, or legacy PENDING)
const secondaryDownload = useMemo<{ version: 'signed' | 'pending'; label: string } | null>(() => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return {
version: 'signed',
label: t({ message: 'Signed', context: 'Signed document (adjective)' }),
};
}
if (envelopeStatus === DocumentStatus.PENDING && !token && !isLegacy) {
return {
version: 'pending',
label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }),
};
}
return null;
}, [envelopeStatus, isLegacy, token, t]);
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpc.envelope.item.getManyByToken.useQuery(
@@ -70,7 +112,7 @@ export const EnvelopeDownloadDialog = ({
const onDownload = async (
envelopeItem: EnvelopeItemToDownload,
version: 'original' | 'signed',
version: 'original' | 'signed' | 'pending',
) => {
const { id: envelopeItemId } = envelopeItem;
@@ -132,7 +174,7 @@ export const EnvelopeDownloadDialog = ({
{Array.from({ length: 1 }).map((_, index) => (
<div
key={index}
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
className="flex items-center gap-2 rounded-lg border border-border bg-card p-4"
>
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
@@ -149,20 +191,20 @@ export const EnvelopeDownloadDialog = ({
envelopeItems.map((item) => (
<div
key={item.id}
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
>
<div className="flex-shrink-0">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<FileTextIcon className="text-primary h-5 w-5" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
</div>
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="text-foreground truncate text-sm font-medium" title={item.title}>
<h4 className="truncate text-sm font-medium text-foreground" title={item.title}>
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<p className="mt-0.5 text-xs text-muted-foreground">
<Trans>PDF Document</Trans>
</p>
</div>
@@ -181,18 +223,20 @@ export const EnvelopeDownloadDialog = ({
<Trans context="Original document (adjective)">Original</Trans>
</Button>
{envelopeStatus === DocumentStatus.COMPLETED && (
{secondaryDownload && (
<Button
variant="default"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, 'signed')}
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
onClick={async () => onDownload(item, secondaryDownload.version)}
loading={
isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)]
}
>
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans context="Signed document (adjective)">Signed</Trans>
{!isDownloadingState[
generateDownloadKey(item.id, secondaryDownload.version)
] && <DownloadIcon className="mr-2 h-4 w-4" />}
{secondaryDownload.label}
</Button>
)}
</div>
+27 -2
View File
@@ -106,6 +106,7 @@ export const SignInForm = ({
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const twoFactorTurnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
@@ -234,6 +235,11 @@ export const SignInForm = ({
if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') {
setIsTwoFactorAuthenticationDialogOpen(true);
// Turnstile tokens are single-use. Clear the consumed one so the
// dialog's fresh widget mounts cleanly and the dialog can't be
// submitted with the stale token before a new one is issued.
setCaptchaToken(null);
return;
}
@@ -393,7 +399,7 @@ export const SignInForm = ({
)}
/>
{turnstileSiteKey && (
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
@@ -545,6 +551,21 @@ export const SignInForm = ({
/>
)}
{turnstileSiteKey && (
<div className="mt-4">
<Turnstile
ref={twoFactorTurnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
}}
/>
</div>
)}
<DialogFooter className="mt-4">
<Button
type="button"
@@ -558,7 +579,11 @@ export const SignInForm = ({
)}
</Button>
<Button type="submit" loading={isSubmitting}>
<Button
type="submit"
loading={isSubmitting}
disabled={Boolean(turnstileSiteKey) && !captchaToken}
>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</DialogFooter>
@@ -104,7 +104,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
token={recipient?.token}
isLegacy={envelope.internalVersion === 1}
token={canManageDocument ? undefined : recipient?.token}
envelopeItems={envelope.envelopeItems}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
@@ -208,8 +208,14 @@ export const EnvelopeEditorRecipientForm = () => {
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
);
const currentEditorEmail = isEmbedded ? editorConfig.embedded?.user?.email : user?.email;
const currentEditorName = isEmbedded ? editorConfig.embedded?.user?.name : user?.name;
const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName);
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
);
const hasDocumentBeenSent = recipients.some(
@@ -344,11 +350,11 @@ export const EnvelopeEditorRecipientForm = () => {
const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', {
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', {
setValue(`signers.${emptySignerIndex}.email`, currentEditorEmail ?? '', {
shouldValidate: true,
shouldDirty: true,
});
@@ -358,8 +364,8 @@ export const EnvelopeEditorRecipientForm = () => {
appendSigner(
{
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
name: currentEditorName ?? '',
email: currentEditorEmail ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder:
@@ -635,7 +641,7 @@ export const EnvelopeEditorRecipientForm = () => {
</Tooltip>
)}
{!isEmbedded && (
{(!isEmbedded || hasCurrentEditorInfo) && (
<Button
variant="outline"
className="flex flex-row items-center"
@@ -495,6 +495,7 @@ export const EnvelopeEditor = () => {
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
isLegacy={envelope.internalVersion === 1}
envelopeItems={envelope.envelopeItems}
trigger={
<Button
@@ -152,7 +152,8 @@ export const DocumentsTableActionDropdown = ({
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
token={recipient?.token}
isLegacy={row.internalVersion === 1}
token={canManageDocument ? undefined : recipient?.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
@@ -51,6 +51,7 @@ const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema
clientId: true,
autoProvisionUsers: true,
defaultOrganisationRole: true,
allowPersonalOrganisations: true,
})
.extend({
clientSecret: z.string().nullable(),
@@ -120,6 +121,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
allowedDomains: authenticationPortal.allowedDomains.join(' '),
allowPersonalOrganisations: authenticationPortal.allowPersonalOrganisations,
},
});
@@ -161,6 +163,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
autoProvisionUsers: values.autoProvisionUsers,
defaultOrganisationRole: values.defaultOrganisationRole,
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
allowPersonalOrganisations: values.allowPersonalOrganisations,
},
});
@@ -390,6 +393,30 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
)}
/> */}
<FormField
control={form.control}
name="allowPersonalOrganisations"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Allow Personal Organisations</Trans>
</FormLabel>
<p className="text-sm text-muted-foreground">
<Trans>
When enabled, users signing in via SSO for the first time will also receive
their own personal organisation.
</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
@@ -298,6 +298,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
mode: 'create' as const,
onCreate: async (envelope: Omit<TEditorEnvelope, 'id'>) => createEmbeddedEnvelope(envelope),
customBrandingLogo: Boolean(teamSettings.brandingEnabled && teamSettings.brandingLogo),
user: embedAuthoringOptions.user,
}),
[token],
);
@@ -314,6 +314,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
mode: 'edit' as const,
onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope),
brandingLogo,
user: embedAuthoringOptions.user,
}),
[token],
);
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.10.0"
"version": "2.10.1"
}
+34 -9
View File
@@ -13,6 +13,7 @@ import { handleEnvelopeItemFileRequest } from '../files/files.helpers';
import {
ZDownloadDocumentRequestParamsSchema,
ZDownloadEnvelopeItemRequestParamsSchema,
ZDownloadEnvelopeItemRequestQuerySchema,
} from './download.types';
export const downloadRoute = new Hono<HonoEnv>()
@@ -23,11 +24,13 @@ export const downloadRoute = new Hono<HonoEnv>()
.get(
'/envelope/item/:envelopeItemId/download',
sValidator('param', ZDownloadEnvelopeItemRequestParamsSchema),
sValidator('query', ZDownloadEnvelopeItemRequestQuerySchema),
async (c) => {
const logger = c.get('logger');
try {
const { envelopeItemId, version } = c.req.valid('param');
const { envelopeItemId } = c.req.valid('param');
const { version } = c.req.valid('query');
const authorizationHeader = c.req.header('authorization');
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
@@ -65,7 +68,16 @@ export const downloadRoute = new Hono<HonoEnv>()
},
},
include: {
envelope: true,
envelope: {
include: {
recipients: {
select: {
role: true,
signingStatus: true,
},
},
},
},
documentData: true,
},
});
@@ -78,23 +90,36 @@ export const downloadRoute = new Hono<HonoEnv>()
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
const baseOptions = {
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
version: version || 'signed',
isDownload: true,
context: c,
} as const;
if (version === 'pending') {
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
envelopeItemId: envelopeItem.id,
envelope: envelopeItem.envelope,
});
}
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
status: envelopeItem.envelope.status,
});
} catch (error) {
logger.error(error);
if (error instanceof AppError) {
if (error.code === AppErrorCode.UNAUTHORIZED) {
return c.json({ error: error.message }, 401);
}
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: error.message }, 400);
// Preserve the existing `{ error }` shape for backwards compatibility;
// `code` is added as a new field for callers that want to branch on it.
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
@@ -2,12 +2,15 @@ import { z } from 'zod';
export const ZDownloadEnvelopeItemRequestParamsSchema = z.object({
envelopeItemId: z.string().describe('The ID of the envelope item to download.'),
});
export const ZDownloadEnvelopeItemRequestQuerySchema = z.object({
version: z
.enum(['original', 'signed'])
.enum(['original', 'signed', 'pending'])
.optional()
.default('signed')
.describe(
'The version of the envelope item to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
'The version of the envelope item to download. "signed" returns the completed document with all signatures and the audit trail, "original" returns the original uploaded document, "pending" returns the original document with currently-inserted fields burned in (only valid while the envelope is in PENDING status; not a final executed document).',
),
});
@@ -15,6 +18,10 @@ export type TDownloadEnvelopeItemRequestParams = z.infer<
typeof ZDownloadEnvelopeItemRequestParamsSchema
>;
export type TDownloadEnvelopeItemRequestQuery = z.infer<
typeof ZDownloadEnvelopeItemRequestQuerySchema
>;
export const ZDownloadDocumentRequestParamsSchema = z.object({
documentId: z.coerce.number().describe('The ID of the document to download.'),
version: z
+167 -13
View File
@@ -2,12 +2,17 @@ import {
type DocumentDataType,
DocumentStatus,
type EnvelopeType,
type RecipientRole,
type SigningStatus,
type TemplateType,
} from '@prisma/client';
import { EnvelopeType as EnvelopeTypeEnum, TemplateType as TemplateTypeEnum } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { type Context } from 'hono';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
@@ -15,30 +20,75 @@ import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
type HandleEnvelopeItemFileRequestOptions = {
title: string;
type DocumentDataInput = {
type: DocumentDataType;
data: string;
initialData: string;
};
type EnvelopeForPendingDownload = {
id: string;
status: DocumentStatus;
documentData: {
type: DocumentDataType;
data: string;
initialData: string;
};
version: 'signed' | 'original';
isDownload: boolean;
context: Context<HonoEnv>;
internalVersion: number;
recipients: Array<{
role: RecipientRole;
signingStatus: SigningStatus;
}>;
};
/**
* Helper function to handle envelope item file requests (both view and download)
* Options shape varies by `version`:
* - `signed` / `original`: serves stored bytes; only needs envelope `status` for cache headers.
* - `pending`: generates a fresh PDF with currently-inserted fields burned in; needs the
* full envelope (id, status, internalVersion, recipients) plus envelopeItemId to query fields.
*/
export const handleEnvelopeItemFileRequest = async ({
type HandleEnvelopeItemFileRequestOptions = {
title: string;
documentData: DocumentDataInput;
isDownload: boolean;
context: Context<HonoEnv>;
} & (
| {
version: 'signed' | 'original';
status: DocumentStatus;
}
| {
version: 'pending';
envelopeItemId: string;
envelope: EnvelopeForPendingDownload;
}
);
/**
* Single entry point for envelope item file requests (view and download).
*
* Dispatches on `version`:
* - `signed` / `original`: returns the stored PDF bytes as-is.
* - `pending`: generates an on-demand PDF with all currently-inserted fields burned in.
*/
export const handleEnvelopeItemFileRequest = async (
options: HandleEnvelopeItemFileRequestOptions,
) => {
if (options.version === 'pending') {
return handlePendingFileRequest(options);
}
return handleStaticFileRequest(options);
};
type StaticFileRequestOptions = Extract<
HandleEnvelopeItemFileRequestOptions,
{ version: 'signed' | 'original' }
>;
const handleStaticFileRequest = async ({
title,
status,
documentData,
version,
isDownload,
context: c,
}: HandleEnvelopeItemFileRequestOptions) => {
}: StaticFileRequestOptions) => {
const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData;
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
@@ -88,6 +138,110 @@ export const handleEnvelopeItemFileRequest = async ({
return c.body(file);
};
type PendingFileRequestOptions = Extract<
HandleEnvelopeItemFileRequestOptions,
{ version: 'pending' }
>;
const handlePendingFileRequest = async ({
title,
envelopeItemId,
envelope,
documentData,
context: c,
}: PendingFileRequestOptions) => {
if (envelope.status !== DocumentStatus.PENDING) {
const errorCode = match(envelope.status)
.with(DocumentStatus.DRAFT, () => AppErrorCode.ENVELOPE_DRAFT)
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
.otherwise(() => AppErrorCode.INVALID_REQUEST);
throw new AppError(errorCode, {
message: `Envelope ${envelope.id} must be pending to download a partially signed PDF`,
statusCode: 400,
});
}
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.ENVELOPE_LEGACY, {
message: `Envelope ${envelope.id} is a legacy envelope and does not support partially signed PDF downloads`,
statusCode: 400,
});
}
const fields = await prisma.field.findMany({
where: {
envelopeItemId,
inserted: true,
},
include: {
signature: true,
},
orderBy: {
id: 'asc',
},
});
const etag = Buffer.from(
sha256(
JSON.stringify({
envelopeStatus: envelope.status,
fields: fields.map((field) => ({
id: field.id,
customText: field.customText,
signatureId: field.signature?.id ?? null,
signatureCreated: field.signature?.created ?? null,
})),
}),
),
).toString('hex');
if (c.req.header('If-None-Match') === etag) {
c.header('ETag', etag);
c.header('Cache-Control', 'no-store, private');
return c.body(null, 304);
}
const file = await getFileServerSide({
type: documentData.type,
data: documentData.initialData,
}).catch((error) => {
console.error(error);
return null;
});
if (!file) {
return c.json({ error: 'File not found' }, 404);
}
const pdf = await generatePartialSignedPdf({
pdfData: file,
fields,
});
c.get('logger').info({
source: 'pendingPdfDownload',
envelopeId: envelope.id,
envelopeItemId,
insertedFieldCount: fields.length,
etag,
});
c.header('Content-Type', 'application/pdf');
c.header('Cache-Control', 'no-store, private');
c.header('ETag', etag);
const baseTitle = title.replace(/\.pdf$/i, '');
const filename = `${baseTitle}_pending.pdf`;
c.header('Content-Disposition', contentDisposition(filename));
return c.body(pdf);
};
type CheckEnvelopeFileAccessOptions = {
userId: number;
teamId: number;
+87 -52
View File
@@ -150,66 +150,101 @@ export const filesRoute = new Hono<HonoEnv>()
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/download/:version?',
sValidator('param', ZGetEnvelopeItemFileDownloadRequestParamsSchema),
async (c) => {
const { envelopeId, envelopeItemId, version } = c.req.valid('param');
const logger = c.get('logger');
const session = await getOptionalSession(c);
try {
const { envelopeId, envelopeItemId, version } = c.req.valid('param');
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const session = await getOptionalSession(c);
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
},
include: {
documentData: true,
},
},
include: {
documentData: true,
recipients: {
select: {
role: true,
signingStatus: true,
},
},
},
},
});
});
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
const hasDownloadAccess = await checkEnvelopeFileAccess({
userId: session.user.id,
teamId: envelope.teamId,
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!hasDownloadAccess) {
return c.json(
{
error: 'User does not have access to the team that this envelope is associated with',
},
403,
);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
const baseOptions = {
title: envelopeItem.title,
documentData: envelopeItem.documentData,
isDownload: true,
context: c,
} as const;
if (version === 'pending') {
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
envelopeItemId: envelopeItem.id,
envelope,
});
}
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
status: envelope.status,
});
} catch (error) {
logger.error(error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
const hasDownloadAccess = await checkEnvelopeFileAccess({
userId: session.user.id,
teamId: envelope.teamId,
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!hasDownloadAccess) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
title: envelopeItem.title,
status: envelope.status,
documentData: envelopeItem.documentData,
version,
isDownload: true,
context: c,
});
},
)
.get(
+1 -1
View File
@@ -56,7 +56,7 @@ export type TGetEnvelopeItemFileTokenRequestParams = z.infer<
export const ZGetEnvelopeItemFileDownloadRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
version: z.enum(['signed', 'original']).default('signed'),
version: z.enum(['signed', 'original', 'pending']).default('signed'),
});
export type TGetEnvelopeItemFileDownloadRequestParams = z.infer<
+36 -24
View File
@@ -19,23 +19,32 @@ const NON_PAGE_PATH_REGEX = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/appl
const EMBED_PATH_REGEX = /^\/embed(\/|\.data|$)/;
/**
* Auth pages reachable from inside an embed iframe during the
* Non-`/embed` page routes that customers iframe directly, plus the auth
* pages reachable from inside an embed iframe during the
* reauth-as-different-account flow.
*
* Signing routes (`/sign/:token`, `/d/:token`):
* Some customer integrations embed these URLs directly (without going
* through `EmbedSignDocument`). Without `frame-ancestors *` here, those
* integrations break with a "refused to connect" iframe error.
*
* Auth routes (`/signin`, `/forgot-password`, `/check-email`,
* `/unverified-account`):
* `apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx`
* does `window.location.href = '/signin?...'` inside the iframe when the
* user needs to sign out and sign back in as a different account, and
* `<SignInForm>` links/navigates to `/forgot-password`, `/check-email`, and
* `/unverified-account` from there.
*
* Without `frame-ancestors *` on these routes, the customer's iframe gets
* blocked the moment the user clicks "Login" in the reauth dialog.
* `/unverified-account` from there. Without `frame-ancestors *` on these
* routes, the customer's iframe gets blocked the moment the user clicks
* "Login" in the reauth dialog.
*
* These routes still get the strict nonced `script-src`/`style-src-elem`
* policy — only `frame-ancestors` is relaxed.
* policy — only `frame-ancestors` is relaxed. The `(\/|\.data|$)` tail
* keeps `/sign` from matching `/signin`/`/signup` and `/d` from matching
* `/dashboard`.
*/
const AUTH_FRAMEABLE_PATH_REGEX =
/^\/(signin|forgot-password|check-email|unverified-account)(\/|\.data|$)/;
const FRAMEABLE_PATH_REGEX =
/^\/(signin|forgot-password|check-email|unverified-account|sign|d)(\/|\.data|$)/;
/**
* Hono context variable name where the per-request CSP nonce is stashed.
@@ -59,7 +68,7 @@ const generateNonce = () => {
return btoa(binary);
};
type CspPathKind = 'embed' | 'auth' | 'default';
type CspPathKind = 'embed' | 'frameable' | 'default';
const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) => {
// `'self'` is included alongside `'strict-dynamic'` as a fallback for
@@ -87,18 +96,18 @@ const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) =
// Embeds inject customer-supplied CSS via runtime-created `<style>`
// elements (see apps/remix/app/utils/css-vars.ts). Nonce-stamping those
// would be brittle for white-label customers, so we accept
// `'unsafe-inline'` on the embed scope only. Auth pages do NOT load
// customer CSS and keep the strict nonced policy.
// `'unsafe-inline'` on the embed scope only. Frameable (auth/signing)
// pages do NOT load customer CSS and keep the strict nonced policy.
if (kind === 'embed') {
directives.push(`style-src-elem 'self' 'unsafe-inline'`);
} else {
directives.push(`style-src-elem 'self' 'nonce-${nonce}'`);
}
// Embed and auth routes are both reachable from inside a customer's
// iframe and therefore need `frame-ancestors *`. Every other page gets
// clickjacking protection.
if (kind === 'embed' || kind === 'auth') {
// Embed, signing, and auth routes are all reachable from inside a
// customer's iframe and therefore need `frame-ancestors *`. Every other
// page gets clickjacking protection.
if (kind === 'embed' || kind === 'frameable') {
directives.push(`frame-ancestors *`);
} else {
directives.push(`frame-ancestors 'self'`);
@@ -112,8 +121,8 @@ const classifyPath = (path: string): CspPathKind => {
return 'embed';
}
if (AUTH_FRAMEABLE_PATH_REGEX.test(path)) {
return 'auth';
if (FRAMEABLE_PATH_REGEX.test(path)) {
return 'frameable';
}
return 'default';
@@ -130,13 +139,16 @@ const classifyPath = (path: string): CspPathKind => {
* Router for `<ServerRouter nonce>` and `<Scripts nonce>` etc.
*
* Path-aware classification:
* - `embed` — wildcard `frame-ancestors`, `'unsafe-inline'` style-src-elem
* (white-label CSS injection), strict nonced script-src.
* - `auth` — wildcard `frame-ancestors` only; needed because the embed
* reauth flow redirects the iframe to `/signin` etc. Strict
* nonced script-src and style-src-elem otherwise.
* - default — strict nonced script-src and style-src-elem,
* `frame-ancestors 'self'` for clickjacking protection.
* - `embed` — wildcard `frame-ancestors`, `'unsafe-inline'`
* style-src-elem (white-label CSS injection), strict
* nonced script-src.
* - `frameable` — wildcard `frame-ancestors` only; needed because the
* embed reauth flow redirects the iframe to `/signin` etc,
* and because some customers iframe `/sign/:token` and
* `/d/:token` directly without using `EmbedSignDocument`.
* Strict nonced script-src and style-src-elem otherwise.
* - default — strict nonced script-src and style-src-elem,
* `frame-ancestors 'self'` for clickjacking protection.
*/
export const securityHeadersMiddleware = createMiddleware<HonoEnv>(async (c, next) => {
const nonce = generateNonce();
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.10.0",
"version": "2.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.10.0",
"version": "2.10.1",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -407,7 +407,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.10.0",
"version": "2.10.1",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
+1 -1
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.10.0",
"version": "2.10.1",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -0,0 +1,251 @@
import { PDF } from '@libpdf/core';
import type { APIRequestContext } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { apiSeedDraftDocument, apiSeedPendingDocument } from '../../fixtures/api-seeds';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const trpcMutation = async (
request: APIRequestContext,
procedure: string,
input: Record<string, unknown>,
) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: {
'content-type': 'application/json',
},
data: JSON.stringify({ json: input }),
});
expect(res.ok(), `${procedure} failed: ${await res.text()}`).toBeTruthy();
};
const getPdfBytes = async (response: Awaited<ReturnType<APIRequestContext['get']>>) => {
const body = await response.body();
expect(body.subarray(0, 5).toString()).toBe('%PDF-');
return new Uint8Array(body);
};
const signAndCompleteRecipient = async ({
request,
token,
documentId,
fieldId,
}: {
request: APIRequestContext;
token: string;
documentId: number;
fieldId: number;
}) => {
await trpcMutation(request, 'envelope.field.sign', {
token,
fieldId,
fieldValue: {
type: FieldType.SIGNATURE,
value: 'Signature',
},
});
await trpcMutation(request, 'recipient.completeDocumentWithToken', {
token,
documentId,
});
};
test.describe('API V2 partial signed PDF downloads', () => {
test('returns a PDF with inserted fields, supports ETag, and rejects after completion', async ({
request,
}) => {
const { envelope, token, distributeResult } = await apiSeedPendingDocument(request, {
recipients: [
{ email: 'partial-signer-1@test.documenso.com', name: 'Partial Signer 1' },
{ email: 'partial-signer-2@test.documenso.com', name: 'Partial Signer 2' },
],
fieldsPerRecipient: [
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 15, height: 5 }],
[
{
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 15,
width: 15,
height: 5,
},
],
],
});
const [recipientOne, recipientTwo] = distributeResult.recipients;
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const envelopeItem = envelope.envelopeItems[0];
const recipientOneField = envelope.fields.find(
(field) => field.recipientId === recipientOne.id && field.type === FieldType.SIGNATURE,
);
const recipientTwoField = envelope.fields.find(
(field) => field.recipientId === recipientTwo.id && field.type === FieldType.SIGNATURE,
);
if (!recipientOneField || !recipientTwoField) {
throw new Error('Expected signature fields not found');
}
await signAndCompleteRecipient({
request,
token: recipientOne.token,
documentId,
fieldId: recipientOneField.id,
});
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: envelope.id,
},
include: {
recipients: true,
},
});
expect(dbEnvelope.status).toBe(DocumentStatus.PENDING);
expect(
dbEnvelope.recipients.find((recipient) => recipient.id === recipientOne.id)?.signingStatus,
).toBe(SigningStatus.SIGNED);
}).toPass();
const downloadUrl = `${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=pending`;
const pendingResponse = await request.get(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(pendingResponse.status()).toBe(200);
expect(pendingResponse.headers()['content-type']).toContain('application/pdf');
expect(pendingResponse.headers()['cache-control']).toBe('no-store, private');
expect(pendingResponse.headers()['content-disposition']).toContain('_pending.pdf');
const etag = pendingResponse.headers().etag;
expect(etag).toBeTruthy();
const pendingPdfBytes = await getPdfBytes(pendingResponse);
const pendingPdf = await PDF.load(pendingPdfBytes);
const originalEnvelopeItem = await prisma.envelopeItem.findUniqueOrThrow({
where: {
id: envelopeItem.id,
},
include: {
documentData: true,
},
});
const originalPdfBytes = await getFileServerSide({
type: originalEnvelopeItem.documentData.type,
data: originalEnvelopeItem.documentData.initialData,
});
const originalPdf = await PDF.load(new Uint8Array(originalPdfBytes));
// Pending PDF should have the same page count as the original (no cert/audit pages).
expect(pendingPdf.getPageCount()).toBe(originalPdf.getPageCount());
const cachedResponse = await request.get(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
'If-None-Match': etag,
},
});
expect(cachedResponse.status()).toBe(304);
await signAndCompleteRecipient({
request,
token: recipientTwo.token,
documentId,
fieldId: recipientTwoField.id,
});
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: envelope.id,
},
});
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
}).toPass({ timeout: 15_000 });
const completedResponse = await request.get(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const completedError = await completedResponse.json();
expect(completedResponse.status()).toBe(400);
expect(completedError.code).toBe('ENVELOPE_COMPLETED');
const signedResponse = await request.get(
`${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=signed`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
expect(signedResponse.status()).toBe(200);
await getPdfBytes(signedResponse);
});
test('rejects draft and legacy pending envelopes', async ({ request }) => {
const draft = await apiSeedDraftDocument(request, {
recipients: [{ email: 'partial-draft@test.documenso.com', name: 'Draft Signer' }],
});
const draftResponse = await request.get(
`${API_BASE_URL}/envelope/item/${draft.envelope.envelopeItems[0].id}/download?version=pending`,
{
headers: {
Authorization: `Bearer ${draft.token}`,
},
},
);
const draftError = await draftResponse.json();
expect(draftResponse.status()).toBe(400);
expect(draftError.code).toBe('ENVELOPE_DRAFT');
const legacy = await apiSeedPendingDocument(request);
await prisma.envelope.update({
where: {
id: legacy.envelope.id,
},
data: {
internalVersion: 1,
},
});
const legacyResponse = await request.get(
`${API_BASE_URL}/envelope/item/${legacy.envelope.envelopeItems[0].id}/download?version=pending`,
{
headers: {
Authorization: `Bearer ${legacy.token}`,
},
},
);
const legacyError = await legacyResponse.json();
expect(legacyResponse.status()).toBe(400);
expect(legacyError.code).toBe('ENVELOPE_LEGACY');
});
});
@@ -76,7 +76,10 @@ export const handleOAuthOrganisationCallbackUrl = async (
},
});
await onCreateUserHook(userToLink).catch((err) => {
await onCreateUserHook(userToLink, {
skipPersonalOrganisation:
!organisation.organisationAuthenticationPortal.allowPersonalOrganisations,
}).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
});
+16 -3
View File
@@ -3,7 +3,7 @@ import type { EnvelopeItem } from '@prisma/client';
import { getEnvelopeItemPdfUrl } from '../utils/envelope-download';
import { downloadFile } from './download-file';
type DocumentVersion = 'original' | 'signed';
type DocumentVersion = 'original' | 'signed' | 'pending';
type DownloadPDFProps = {
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
@@ -14,10 +14,24 @@ type DownloadPDFProps = {
* Specifies which version of the document to download.
* 'signed': Downloads the signed version (default).
* 'original': Downloads the original version.
* 'pending': Downloads the original document with currently-inserted fields burned in.
* Only valid while the envelope is in PENDING status. Not supported via
* recipient token.
*/
version?: DocumentVersion;
};
const versionToFilenameSuffix = (version: DocumentVersion): string => {
switch (version) {
case 'signed':
return '_signed.pdf';
case 'pending':
return '_pending.pdf';
case 'original':
return '.pdf';
}
};
export const downloadPDF = async ({
envelopeItem,
token,
@@ -34,10 +48,9 @@ export const downloadPDF = async ({
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
downloadFile({
filename: `${baseTitle}${suffix}`,
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
data: blob,
});
};
+24 -2
View File
@@ -12,15 +12,21 @@ export enum AppErrorCode {
'RECIPIENT_EXPIRED' = 'RECIPIENT_EXPIRED',
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
'NOT_FOUND' = 'NOT_FOUND',
'NOT_IMPLEMENTED' = 'NOT_IMPLEMENTED',
'NOT_SETUP' = 'NOT_SETUP',
'INVALID_CAPTCHA' = 'INVALID_CAPTCHA',
'UNAUTHORIZED' = 'UNAUTHORIZED',
'FORBIDDEN' = 'FORBIDDEN',
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
'WEBHOOK_INVALID_REQUEST' = 'WEBHOOK_INVALID_REQUEST',
'ENVELOPE_DRAFT' = 'ENVELOPE_DRAFT',
'ENVELOPE_COMPLETED' = 'ENVELOPE_COMPLETED',
'ENVELOPE_REJECTED' = 'ENVELOPE_REJECTED',
'ENVELOPE_LEGACY' = 'ENVELOPE_LEGACY',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
@@ -32,13 +38,19 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
};
export const ZAppErrorJsonSchema = z.object({
@@ -216,15 +228,25 @@ export class AppError extends Error {
}
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
status: 400 | 401 | 403 | 404 | 500 | 501;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(
AppErrorCode.INVALID_BODY,
AppErrorCode.INVALID_REQUEST,
AppErrorCode.ENVELOPE_DRAFT,
AppErrorCode.ENVELOPE_COMPLETED,
AppErrorCode.ENVELOPE_REJECTED,
AppErrorCode.ENVELOPE_LEGACY,
() => 400 as const,
)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.FORBIDDEN, () => 403 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
.otherwise(() => 500 as const);
return {
@@ -0,0 +1,80 @@
import { PDF } from '@libpdf/core';
import { groupBy } from 'remeda';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { insertFieldInPDFV2 } from './insert-field-in-pdf-v2';
type GeneratePartialSignedPdfOptions = {
pdfData: Uint8Array;
fields: FieldWithSignature[];
};
/**
* Generates a PDF with all currently-inserted fields burned in. Used to serve
* partially signed envelopes during the `PENDING` window before the seal job
* has had a chance to produce the final sealed PDF.
*
* No PKI signature, no certificate page, no audit log appendix - this is a
* preview of the in-progress envelope, not a final executed document.
*/
export const generatePartialSignedPdf = async ({
pdfData,
fields,
}: GeneratePartialSignedPdfOptions) => {
const pdfDoc = await PDF.load(pdfData);
pdfDoc.flattenAll();
pdfDoc.upgradeVersion('1.7');
const fieldsGroupedByPage = groupBy(fields, (field) => field.page);
for (const [pageNumber, pageFields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1);
if (!page) {
throw new Error(`Page ${pageNumber} does not exist`);
}
const pageWidth = page.width;
const pageHeight = page.height;
const overlayBytes = await insertFieldInPDFV2({
pageWidth,
pageHeight,
fields: pageFields,
});
const overlayPdf = await PDF.load(overlayBytes);
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
let translateX = 0;
let translateY = 0;
switch (page.rotation) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
}
page.drawPage(embeddedPage, {
x: translateX,
y: translateY,
rotate: {
angle: page.rotation,
},
});
}
pdfDoc.flattenAll();
return await pdfDoc.save({ useXRefStream: true });
};
+16 -2
View File
@@ -60,13 +60,27 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
return user;
};
export type OnCreateUserHookOptions = {
/**
* When true, do not create a "Personal Organisation" for the new user.
* Used by the Organisation SSO signup path, where the user is intended
* to operate inside the SSO organisation rather than a personal space.
*
* Defaults to false — preserves the historical behaviour of creating a
* personal organisation for every new user.
*/
skipPersonalOrganisation?: boolean;
};
/**
* Should be run after a user is created, example during email password signup or google sign in.
*
* @returns User
*/
export const onCreateUserHook = async (user: User) => {
await createPersonalOrganisation({ userId: user.id });
export const onCreateUserHook = async (user: User, options: OnCreateUserHookOptions = {}) => {
if (!options.skipPersonalOrganisation) {
await createPersonalOrganisation({ userId: user.id });
}
return user;
};
+16
View File
@@ -220,11 +220,23 @@ export const ZEmbedCreateEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
type: z.nativeEnum(EnvelopeType),
folderId: z.string().optional(),
user: z
.object({
email: z.string().email().optional(),
name: z.string().optional(),
})
.optional(),
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
});
export const ZEmbedEditEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
user: z
.object({
email: z.string().email().optional(),
name: z.string().optional(),
})
.optional(),
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
});
@@ -323,5 +335,9 @@ export type EnvelopeEditorConfig = TEnvelopeEditorSettings & {
onCreate?: (envelope: Omit<TEditorEnvelope, 'id'>) => void;
onUpdate?: (envelope: TEditorEnvelope) => void;
customBrandingLogo?: boolean;
user?: {
email?: string;
name?: string;
};
};
};
+5 -1
View File
@@ -4,12 +4,16 @@ import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
/**
* `pending` is only supported when there is no recipient token (team/owner-side downloads
* via the session-authed file route). The recipient-token route does not accept `pending`.
*/
export type EnvelopeItemPdfUrlOptions =
| {
type: 'download';
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
version: 'original' | 'signed';
version: 'original' | 'signed' | 'pending';
presignToken?: undefined;
}
| {
@@ -0,0 +1,10 @@
-- AlterTable
-- Add the column with a temporary default of `true` so that all existing rows
-- (representing organisations created before this feature) are backfilled to
-- `true` — preserving the historical behaviour of creating personal
-- organisations for SSO-provisioned users.
ALTER TABLE "OrganisationAuthenticationPortal" ADD COLUMN "allowPersonalOrganisations" BOOLEAN NOT NULL DEFAULT true;
-- Switch the column default to `false` so that any organisations created from
-- now on opt out of personal-organisation creation by default.
ALTER TABLE "OrganisationAuthenticationPortal" ALTER COLUMN "allowPersonalOrganisations" SET DEFAULT false;
+4 -3
View File
@@ -1100,9 +1100,10 @@ model OrganisationAuthenticationPortal {
clientSecret String @default("")
wellKnownUrl String @default("")
defaultOrganisationRole OrganisationMemberRole @default(MEMBER)
autoProvisionUsers Boolean @default(true)
allowedDomains String[] @default([])
defaultOrganisationRole OrganisationMemberRole @default(MEMBER)
autoProvisionUsers Boolean @default(true)
allowedDomains String[] @default([])
allowPersonalOrganisations Boolean @default(false)
}
model Counter {
@@ -52,6 +52,7 @@ export const getOrganisationAuthenticationPortal = async ({
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
allowPersonalOrganisations: true,
clientSecret: true,
},
},
@@ -79,6 +80,7 @@ export const getOrganisationAuthenticationPortal = async ({
wellKnownUrl: portal.wellKnownUrl,
autoProvisionUsers: portal.autoProvisionUsers,
allowedDomains: portal.allowedDomains,
allowPersonalOrganisations: portal.allowPersonalOrganisations,
clientSecretProvided: Boolean(portal.clientSecret),
};
};
@@ -14,6 +14,7 @@ export const ZGetOrganisationAuthenticationPortalResponseSchema =
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
allowPersonalOrganisations: true,
}).extend({
/**
* Whether we have the client secret in the database.
@@ -61,6 +61,7 @@ export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedur
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
allowPersonalOrganisations,
} = data;
if (
@@ -104,6 +105,7 @@ export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedur
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
allowPersonalOrganisations,
},
});
});
@@ -14,6 +14,7 @@ export const ZUpdateOrganisationAuthenticationPortalRequestSchema = z.object({
wellKnownUrl: z.union([z.string().url(), z.literal('')]),
autoProvisionUsers: z.boolean(),
allowedDomains: z.array(z.string().regex(domainRegex)),
allowPersonalOrganisations: z.boolean(),
}),
});
@@ -18,9 +18,9 @@ export const downloadEnvelopeItemMeta: TrpcRouteMeta = {
export const ZDownloadEnvelopeItemRequestSchema = z.object({
envelopeItemId: z.string().describe('The ID of the envelope item to download.'),
version: z
.enum(['original', 'signed'])
.enum(['original', 'signed', 'pending'])
.describe(
'The version of the envelope item to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
'The version of the envelope item to download. "signed" returns the completed document with all signatures and the audit trail, "original" returns the original uploaded document, "pending" returns the original document with currently-inserted fields burned in (only valid while the envelope is in PENDING status; not a final executed document).',
)
.default('signed'),
});