Compare commits

..

12 Commits

Author SHA1 Message Date
ephraimduncan 62541f81b2 fix(share): redirect unknown qr share tokens home instead of 404 2026-07-02 15:58:26 +00:00
ephraimduncan 3283251c3f fix(ui): hide single-item file selector only in QR share view 2026-07-02 15:39:58 +00:00
ephraimduncan 475ce7a12d Merge remote-tracking branch 'origin/main' into pr-2559 2026-07-02 15:36:15 +00:00
Ephraim Duncan 34476833d6 Merge branch 'main' into feat/public-completed-document-access 2026-05-27 13:46:05 +00:00
ephraimduncan cb9c892dbe Merge remote-tracking branch 'origin/main' into pr-2559
# Conflicts:
#	packages/lib/translations/de/web.po
#	packages/lib/translations/en/web.po
#	packages/lib/translations/es/web.po
#	packages/lib/translations/fr/web.po
#	packages/lib/translations/it/web.po
#	packages/lib/translations/ja/web.po
#	packages/lib/translations/ko/web.po
#	packages/lib/translations/nl/web.po
#	packages/lib/translations/pl/web.po
#	packages/lib/translations/pt-BR/web.po
#	packages/lib/translations/zh/web.po
2026-05-14 15:44:00 +00:00
ephraimduncan 2c77ec396a chore: merge main, resolve biome formatting conflicts 2026-05-12 11:36:35 +00:00
ephraimduncan 1bd6588a1e fix: accept string title in appMetaTags for dynamic document titles 2026-04-22 18:53:24 +00:00
ephraimduncan f0e43f09fd Merge branch 'main' into feat/public-completed-document-access 2026-04-22 18:29:11 +00:00
ephraimduncan 74042c7c6e chore: remove flaky recipient completed PDF share e2e test 2026-03-04 17:24:47 +00:00
ephraimduncan 5ce4b59f52 chore: remove unnecessary constant extraction and defensive optional chaining 2026-03-04 17:08:10 +00:00
ephraimduncan 77e463e850 refactor: deduplicate QR share helpers and fix polling leak
Extract shared tokenFingerprint() and isPublicDocumentAccessEnabled()
helpers, reuse rateLimitResponse from rate-limit-middleware, deduplicate
Prisma include across view/download handlers, convert error if-chain to
if/else-if, move MAX_QR_RETRY_COUNT to module scope, and stop
refetchInterval once document reaches terminal status.
2026-03-04 16:06:28 +00:00
ephraimduncan acb5a885c2 feat: allow recipients to view completed PDF via QR share link
Add allowPublicCompletedDocumentAccess toggle at org/team level
(team inherits from org via null). Recipients see a "View completed
PDF" button on the signing completion page that links to /share/qr_*.

- DB migration adding toggle to OrganisationGlobalSettings and TeamGlobalSettings
- Settings UI for org and team document preferences
- Rate limiting on QR share view and file download endpoints
- Structured error responses with support codes in share route ErrorBoundary
- Exponential backoff retry when qrToken not yet available post-completion
- QR-authenticated viewers restricted to signed PDF only (no original)
- E2E tests covering happy path, not-yet-completed, invalid token, and toggle revocation
2026-03-04 14:45:16 +00:00
50 changed files with 2161 additions and 2830 deletions
@@ -0,0 +1,199 @@
---
date: 2026-03-02
title: View Pdf As Recipient Online After Completion Via Qr Share Url Outside Team
---
## Goal
Allow a recipient (including users outside the owning team) to open the final completed PDF online from the post-completion experience.
## Non-Goals
- No support for historical completed documents that predate this change.
- No recipient access to draft versions, intermediate revisions, or team-internal metadata.
- No rollout flag/canary; ship enabled by default.
## Current State
Only sender/team-member paths reliably reach the online PDF viewer. Recipient-side access after signing can fail when authorization assumes team membership.
## Product Decisions Captured
- Authorization primitive: signed recipient token (recipient + document scoped).
- URL availability: generate and persist synchronously before showing completion CTA.
- Revocation: inherit existing document share/QR toggle behavior.
- Security controls in v1: access logs plus per-token + IP rate limiting.
- Token binding strictness: recipient + document binding only (no device/IP hard binding).
- Artifact visibility: final completed PDF only.
- Backward compatibility: only new completions are supported.
- Multi-recipient policy: recipient can view only after whole document is fully completed.
- Failure UX: inline retry with backoff and support code.
- Rollout: enabled globally by default.
## Detailed Plan
1. Trace and reuse the existing QR/share URL generation source for completed documents.
2. Ensure share URL/token material is created transactionally in the completion finalization flow, before completion CTA rendering.
3. Wire recipient post-completion CTA to this shared URL source (not team-member viewer route assumptions).
4. Add a dedicated authorization path for recipient online-view requests:
- Validate signed token.
- Confirm token recipientId matches a recipient on the target document.
- Confirm token documentId matches request document.
- Confirm document status is fully completed.
- Confirm share/QR access is currently enabled.
5. Keep sender behavior unchanged; sender paths continue through existing sender/team rules.
6. Add fallback UX for missing/failed share URL generation with bounded retry and support code.
7. Add observability and abuse controls (logs, rate limits) on recipient view endpoint.
8. Add and update automated tests for happy path and denial path coverage.
## Authorization and Security Model
### Access Contract
- Recipient view endpoint accepts a signed recipient token (bearer).
- Token claims should include at minimum:
- `documentId`
- `recipientId`
- `completedAt` (or equivalent anti-stale marker)
- `exp` (bounded expiry)
- Team membership is not required for this path.
### Access Denial Conditions
- Invalid signature, expired token, or malformed claims.
- Token/document mismatch or token/recipient mismatch.
- Document not fully completed.
- Share/QR feature disabled/revoked for document.
- Rate limit exceeded.
### Rate Limiting
- Apply sliding window limits keyed by token fingerprint + source IP.
- Return `429` with `Retry-After` on throttle.
- Log throttle events with reason and correlation id.
### Audit Logging
- Log recipient view attempts (allow + deny) with:
- document id
- recipient id (if resolvable)
- result (allow/deny)
- deny reason code
- IP and user-agent
- request correlation id
## UX and Behavior
### Post-Completion CTA
- Completion screen includes `View completed PDF` CTA for recipients.
- CTA is rendered only after synchronous URL generation succeeds.
### Failure Handling
- If synchronous generation fails, keep user on completion success context and show:
- clear inline error
- retry action with exponential backoff (bounded attempts)
- support code/correlation id for escalation
- Do not expose internal stack details.
### Multi-Recipient Behavior
- Recipient access is blocked until all required recipients are completed and document is in final completed state.
## Data and Lifecycle
- Reuse existing share URL persistence model.
- Generate token/share material during completion finalization for new completions only.
- No retroactive migration for previously completed documents.
## API / Endpoint Expectations
- Recipient viewer endpoint should return:
- `200` with final PDF viewer payload on success
- `401/403` for token/authz failures (reason mapped to safe frontend message)
- `404` if document is not visible via token context
- `409` if document not yet fully completed
- `429` when rate-limited
- Error responses should expose stable error codes consumable by frontend copy mapping.
## Testing Strategy
### Unit / Integration
- Token validation and claim mismatch rejection.
- Denial when document incomplete.
- Denial when share toggle disabled.
- Rate-limit enforcement behavior.
### End-to-End
- Sender path unaffected (regression).
- Recipient outside team can open final PDF after full completion.
- Recipient cannot open before full completion.
- Unauthorized/random user without valid token is denied.
- Failure fallback UI shows retry + support code on forced generation failure.
## Validation Criteria
- Recipient can open completed PDF online from completion context without team membership.
- Final-PDF-only visibility is enforced.
- Sender behavior remains unchanged.
- Unauthorized users still cannot access the document.
- Share-toggle revocation immediately removes recipient access.
- Access events and rate-limit events are observable in logs.
## Risks and Mitigations
- Risk: broader access than intended.
- Mitigation: strict recipient+document token checks, completed-state check, share-toggle gate.
- Risk: completion-time URL generation increases latency.
- Mitigation: keep generation in bounded transaction path, add retry fallback UX, log latency.
- Risk: support confusion for pre-existing completed documents.
- Mitigation: document "new completions only" behavior in release notes/internal support docs.
## Implementation Checklist (Repo-Mapped)
1. Completion UX entry point (recipient side)
- File: `apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx`
- Replace/augment current post-completion action so recipients get a direct `View completed PDF` action that points to `/share/${document.qrToken}` when available.
- Gate visibility on final completion state (`signingStatus === 'COMPLETED'`) and `document.qrToken` presence.
- Keep download and existing sender/home actions unchanged.
2. Ensure QR token availability at completion time
- File: `packages/lib/jobs/definitions/internal/seal-document.handler.ts`
- Preserve existing `qrToken` creation in sealing flow and confirm it runs before the recipient completion page can surface the final view action.
- If race conditions appear (status completed but `qrToken` missing), add an explicit short polling/fallback state in UI rather than exposing a broken link.
3. Recipient-readable completed document route
- File: `apps/remix/app/routes/_share+/share.$slug.tsx`
- Keep `qr_` branch as the recipient-safe online view path and ensure it stays independent from team membership checks.
- Keep non-`qr_` slug behavior (social share redirects/meta) unchanged.
4. Access/read model for QR token
- File: `packages/lib/server-only/document/get-document-by-access-token.ts`
- Maintain strict completed-document-only lookup (`status: COMPLETED`) and minimal selected payload.
- Verify returned payload remains final-artifact-only (no draft/intermediate data leakage).
5. Existing share-link path boundary (non-goal guardrail)
- Files: `packages/trpc/server/document-router/share-document.ts`, `packages/lib/server-only/share/create-or-get-share-link.ts`
- Do not repurpose social `DocumentShareLink` as authorization source for final PDF access in this change.
- Keep this as a separate concern from QR token completed-document viewing.
6. Logging and throttling hooks
- Files: `apps/remix/server/router.ts`, `packages/lib/server-only/rate-limit/rate-limit.ts`, `packages/lib/server-only/rate-limit/rate-limit-middleware.ts`
- Add or reuse per-route limits for `/share/qr_*` access attempts.
- Log allow/deny/throttle events with correlation id to support abuse triage.
7. Test coverage targets
- Add route/component coverage for recipient completion page behavior in `apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx` flows.
- Add integration coverage for QR route access in `apps/remix/app/routes/_share+/share.$slug.tsx` + `packages/lib/server-only/document/get-document-by-access-token.ts`.
- Add E2E scenario under `packages/app-tests/e2e/document-auth/` for:
- recipient outside team sees and uses `View completed PDF` after full completion,
- recipient does not see final-view action before full completion,
- invalid/random QR token is denied.
8. Verification commands
- Typecheck changed TS packages: `npx tsc --noEmit`
- Run affected tests (targeted): `npm run test:dev -w @documenso/app-tests`
- Optional broader confidence (if needed): `npm run lint`
@@ -76,8 +76,6 @@ The Enterprise Edition is required when you:
4. Restart your Documenso instance
5. Verify the license is active in the **Admin Panel** under the **Stats** section
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
</Accordion>
</Accordions>
@@ -199,7 +197,7 @@ See [Support](/docs/policies/support) for complete support options.
1. Sign the Enterprise license agreement
2. Receive license key and access credentials
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
4. Configure Enterprise features with support assistance
</Step>
<Step>
@@ -240,7 +238,6 @@ See [Support](/docs/policies/support) for complete support options.
## Related
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
- [Support](/docs/policies/support) - Support channels and response times
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
## Enterprise Features
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
| Variable | Description |
| ------------------------------------ | ------------------------------------------------ |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
@@ -1,107 +0,0 @@
---
title: Apply Your License Key
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
<Callout type="info">
The license key applies to your **whole instance**, not an individual user account. There's one
key per deployment.
</Callout>
## Prerequisites
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
- A running self-hosted Documenso instance that you're able to restart
## Step 1: Set the environment variable
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
<Tabs items={['Docker Compose', 'docker run', '.env']}>
<Tab value="Docker Compose">
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
Then apply it:
```bash
docker compose up -d
```
</Tab>
<Tab value="docker run">
```bash
docker run -d \
--name documenso \
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
documenso/documenso:latest
```
</Tab>
<Tab value=".env">
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
```
</Tab>
</Tabs>
## Step 2: Restart the instance
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
```bash
# Docker Compose
docker compose restart documenso
# Docker
docker restart documenso
```
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
## What the license enables
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
## Troubleshooting
<Accordions type="multiple">
<Accordion title="Enterprise features are still unavailable after applying the key">
- Confirm the key is present in the environment the running process actually reads — `docker
exec` into the container and check `env | grep LICENSE` if unsure.
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
- Re-copy the key to rule out truncation or accidental whitespace.
</Accordion>
<Accordion title="A specific feature still isn't working">
Instance-wide features (like CSC signing) also need their own configuration — an active license
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
Per-organisation features additionally need to be provisioned for the organisation that's using
them.
</Accordion>
</Accordions>
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
@@ -2,7 +2,6 @@
"title": "Configuration",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</Step>
<Step>
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
---
@@ -103,6 +103,7 @@ export const EnvelopeDownloadDialog = ({
);
const envelopeItems = envelopeItemsPayload?.data || [];
const isQrToken = Boolean(token?.startsWith('qr_'));
const onDownload = async (envelopeItem: EnvelopeItemToDownload, version: 'original' | 'signed' | 'pending') => {
const { id: envelopeItemId } = envelopeItem;
@@ -160,41 +161,41 @@ export const EnvelopeDownloadDialog = ({
</DialogHeader>
<div className="flex w-full flex-col gap-4 overflow-hidden">
{isLoadingEnvelopeItems
? Array.from({ length: 1 }).map((_, index) => (
<div key={index} 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" />
{isLoadingEnvelopeItems ? (
<div 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" />
<div className="flex w-full flex-col gap-2">
<Skeleton className="h-4 w-28 rounded-lg" />
<Skeleton className="h-4 w-20 rounded-lg" />
<div className="flex w-full flex-col gap-2">
<Skeleton className="h-4 w-28 rounded-lg" />
<Skeleton className="h-4 w-20 rounded-lg" />
</div>
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
</div>
) : (
envelopeItems.map((item) => (
<div
key={item.id}
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="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
</div>
))
: envelopeItems.map((item) => (
<div
key={item.id}
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="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="truncate font-medium text-foreground text-sm" title={item.title}>
{item.title}
</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
<Trans>PDF Document</Trans>
</p>
</div>
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="truncate font-medium text-foreground text-sm" title={item.title}>
{item.title}
</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
<Trans>PDF Document</Trans>
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<div className="flex flex-shrink-0 items-center gap-2">
{!isQrToken && (
<Button
variant="outline"
size="sm"
@@ -207,24 +208,26 @@ export const EnvelopeDownloadDialog = ({
)}
<Trans context="Original document (adjective)">Original</Trans>
</Button>
)}
{secondaryDownload && (
<Button
variant="default"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, secondaryDownload.version)}
loading={isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)]}
>
{!isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
{secondaryDownload.label}
</Button>
)}
</div>
{secondaryDownload && (
<Button
variant="default"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, secondaryDownload.version)}
loading={isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)]}
>
{!isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
{secondaryDownload.label}
</Button>
)}
</div>
))}
</div>
))
)}
</div>
</DialogContent>
</Dialog>
@@ -58,6 +58,7 @@ export type TDocumentPreferencesFormSchema = {
documentDateFormat: TDocumentMetaDateFormat | null;
includeSenderDetails: boolean | null;
includeSigningCertificate: boolean | null;
allowPublicCompletedDocumentAccess: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
@@ -75,6 +76,7 @@ type SettingsSubset = Pick<
| 'documentDateFormat'
| 'includeSenderDetails'
| 'includeSigningCertificate'
| 'allowPublicCompletedDocumentAccess'
| 'includeAuditLog'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
@@ -116,6 +118,7 @@ export const DocumentPreferencesForm = ({
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
allowPublicCompletedDocumentAccess: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
@@ -136,6 +139,7 @@ export const DocumentPreferencesForm = ({
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
allowPublicCompletedDocumentAccess: settings.allowPublicCompletedDocumentAccess,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
@@ -483,6 +487,58 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="allowPublicCompletedDocumentAccess"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Allow Public Access to Completed Documents via QR/Share Link</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="allow-public-completed-document-access-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether recipients can open completed documents online through QR/share links, including
recipients outside your team.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
+1 -1
View File
@@ -96,7 +96,7 @@ export const SignUpForm = ({
password: '',
signature: '',
},
mode: 'onChange',
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormSchema),
});
@@ -1,23 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
export const DirectTemplateInvalidPageView = () => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
<h1 className="mt-4 font-semibold text-3xl">
<Trans>Invalid direct link template</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field assigned.
Please contact the sender to update the template.
</Trans>
</p>
</div>
</div>
);
};
@@ -2,6 +2,7 @@ import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@@ -49,9 +50,9 @@ export const DocumentCertificateQRView = ({
completedDate,
token,
}: DocumentCertificateQRViewProps) => {
const { data: documentViaUser } = trpc.document.get.useQuery({
documentId,
});
const { sessionData } = useOptionalSession();
const { data: documentViaUser } = trpc.document.get.useQuery({ documentId }, { enabled: !!sessionData?.user });
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentViaUser);
@@ -206,7 +207,9 @@ const DocumentCertificateQrV2 = ({ title, recipientCount, formattedDate, token }
</div>
<div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
{envelopeItems.length > 1 && (
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
)}
<EnvelopePdfViewer
scrollParentRef="window"
@@ -54,7 +54,6 @@ import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
@@ -239,8 +238,6 @@ export const EnvelopeEditorFieldsPage = () => {
}
/>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
@@ -1,55 +0,0 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
export type EnvelopeEditorInvalidDirectTemplateAlertProps = {
className?: string;
};
/**
* Warns that a direct link template cannot be used because one or more signers
* are missing a signature field.
*/
export const EnvelopeEditorInvalidDirectTemplateAlert = ({
className,
}: EnvelopeEditorInvalidDirectTemplateAlertProps) => {
const { envelope, isTemplate } = useCurrentEnvelopeEditor();
const signersMissingSignatureFields = useMemo(() => {
if (!isTemplate || !envelope.directLink?.enabled) {
return [];
}
return getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
}, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]);
if (signersMissingSignatureFields.length === 0) {
return null;
}
return (
<Alert
variant="destructive"
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
>
<AlertTitle>
<Trans>Invalid direct link template</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Recipients cannot use this direct link template because the following signers are missing a signature field
</Trans>
<ul className="list-disc pl-5">
{signersMissingSignatureFields.map((recipient, i) => (
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
};
@@ -22,7 +22,6 @@ import { match } from 'ts-pattern';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
export const EnvelopeEditorPreviewPage = () => {
@@ -229,8 +228,6 @@ export const EnvelopeEditorPreviewPage = () => {
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
<Alert variant="warning" className="mx-auto max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
@@ -26,7 +26,6 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
@@ -450,9 +449,6 @@ export const EnvelopeEditorUploadPage = () => {
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>
@@ -53,6 +53,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
signatureTypes,
defaultRecipients,
@@ -68,6 +69,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null ||
allowPublicCompletedDocumentAccess === null ||
includeAuditLog === null ||
aiFeaturesEnabled === null
) {
@@ -83,6 +85,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
defaultRecipients,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
@@ -48,6 +48,7 @@ export default function TeamsSettingsPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
signatureTypes,
defaultRecipients,
@@ -66,6 +67,7 @@ export default function TeamsSettingsPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
defaultRecipients,
aiFeaturesEnabled,
@@ -7,7 +7,6 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { Plural } from '@lingui/react/macro';
import { UsersIcon } from 'lucide-react';
@@ -15,7 +14,6 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
@@ -72,18 +70,8 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
};
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields);
if (recipientsWithMissingFields.length > 0) {
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: true,
} as const;
}
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: false,
template: {
...template,
folder: null,
@@ -108,7 +96,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
.then((envelopeForSigning) => {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: false,
envelopeForSigning,
} as const;
})
@@ -121,13 +108,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
} as const;
}
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: true,
} as const;
}
throw new Response('Not Found', { status: 404 });
});
};
@@ -201,10 +181,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DirectTemplateAuthPageView />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { template, directTemplateRecipient } = data;
return (
@@ -259,10 +235,6 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
@@ -16,11 +16,11 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { CheckCircle2, Clock8, DownloadIcon, Loader2 } from 'lucide-react';
import { Link } from 'react-router';
import { CheckCircle2, Clock8, DownloadIcon, EyeIcon, Loader2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
@@ -31,6 +31,8 @@ import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/complete';
const MAX_QR_RETRY_COUNT = 4;
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
@@ -103,32 +105,26 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}
export default function CompletedSigningPage({ loaderData }: Route.ComponentProps) {
const { _ } = useLingui();
const revalidator = useRevalidator();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const cspNonce = useCspNonce();
const {
isDocumentAccessValid,
canSignUp,
recipientName,
signatures,
document,
recipient,
recipientEmail,
returnToHomePath,
branding,
} = loaderData;
const { isDocumentAccessValid, recipientEmail, branding } = loaderData;
const signingStatusToken = isDocumentAccessValid ? loaderData.recipient.token : '';
const initialSigningStatus = isDocumentAccessValid ? loaderData.document.status : DocumentStatus.PENDING;
// Poll signing status every few seconds
const { data: signingStatusData } = trpc.envelope.signingStatus.useQuery(
{
token: recipient?.token || '',
token: signingStatusToken,
},
{
refetchInterval: 3000,
initialData: match(document?.status)
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'COMPLETED' || status === 'REJECTED' ? false : 3000;
},
initialData: match(initialSigningStatus)
.with(DocumentStatus.COMPLETED, () => ({ status: 'COMPLETED' }) as const)
.with(DocumentStatus.REJECTED, () => ({ status: 'REJECTED' }) as const)
.with(DocumentStatus.PENDING, () => ({ status: 'PENDING' }) as const)
@@ -136,9 +132,56 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
},
);
// Use signing status from query if available, otherwise fall back to document status
const signingStatus = signingStatusData?.status ?? 'PENDING';
const [qrRetryCount, setQrRetryCount] = useState(0);
const [isRetryingQrLink, setIsRetryingQrLink] = useState(false);
const onRetryQrLink = useCallback(async () => {
if (!isDocumentAccessValid) {
return;
}
if (qrRetryCount >= MAX_QR_RETRY_COUNT) {
return;
}
setIsRetryingQrLink(true);
const nextRetryCount = qrRetryCount + 1;
const retryDelay = Math.min(250 * 2 ** nextRetryCount, 3000);
await new Promise<void>((resolve) => {
setTimeout(resolve, retryDelay);
});
setQrRetryCount(nextRetryCount);
try {
await revalidator.revalidate();
} finally {
setIsRetryingQrLink(false);
}
}, [isDocumentAccessValid, qrRetryCount, revalidator]);
const isFullyCompleted = isDocumentAccessValid && signingStatus === 'COMPLETED';
const hasQrToken = isDocumentAccessValid && Boolean(loaderData.document.qrToken);
const supportCode = isDocumentAccessValid ? `QR-${loaderData.document.id}-${loaderData.recipient.id}` : '';
useEffect(() => {
if (
!isFullyCompleted ||
hasQrToken ||
isRetryingQrLink ||
revalidator.state !== 'idle' ||
qrRetryCount >= MAX_QR_RETRY_COUNT
) {
return;
}
void onRetryQrLink();
}, [hasQrToken, isFullyCompleted, isRetryingQrLink, onRetryQrLink, qrRetryCount, revalidator]);
if (!isDocumentAccessValid) {
return (
<>
@@ -148,6 +191,8 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
);
}
const { canSignUp, recipientName, signatures, document, recipient, returnToHomePath } = loaderData;
return (
<>
<RecipientBranding branding={branding} cspNonce={cspNonce} />
@@ -249,6 +294,40 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
))}
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
{isFullyCompleted && hasQrToken && (
<Button asChild variant="secondary" className="w-full">
<Link to={`/share/${document.qrToken}`} target="_blank" rel="noopener noreferrer">
<EyeIcon className="mr-2 h-5 w-5" />
<Trans>View completed PDF</Trans>
</Link>
</Button>
)}
{isFullyCompleted && !hasQrToken && (
<div className="w-full rounded-md border border-orange-200 bg-orange-50 p-3 text-left text-orange-900 text-sm md:max-w-sm">
<p>
<Trans>We are preparing your online PDF view. If it does not appear, retry below.</Trans>
</p>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="font-medium text-orange-700 text-xs uppercase tracking-wide">
<Trans>Support code</Trans>: {supportCode}
</span>
<Button
type="button"
variant="outline"
size="sm"
loading={isRetryingQrLink || revalidator.state === 'loading'}
disabled={qrRetryCount >= MAX_QR_RETRY_COUNT}
onClick={() => void onRetryQrLink()}
>
<Trans>Retry</Trans>
</Button>
</div>
</div>
)}
<DocumentShareButton
documentId={document.id}
token={recipient.token}
+203 -13
View File
@@ -1,14 +1,27 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentByAccessToken } from '@documenso/lib/server-only/document/get-document-by-access-token';
import { redirect, useLoaderData } from 'react-router';
import { qrShareViewRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { tokenFingerprint } from '@documenso/lib/universal/crypto';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { logger } from '@documenso/lib/utils/logger';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { AlertCircle } from 'lucide-react';
import { nanoid } from 'nanoid';
import { isRouteErrorResponse, Link, redirect, useLoaderData } from 'react-router';
import { match } from 'ts-pattern';
import { DocumentCertificateQRView } from '~/components/general/document/document-certificate-qr-view';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/share.$slug';
export function meta({ params: { slug } }: Route.MetaArgs) {
export function meta({ params: { slug }, loaderData }: Route.MetaArgs) {
if (slug.startsWith('qr_')) {
return undefined;
const documentTitle = loaderData?.document?.title ?? 'Shared Document';
return [...appMetaTags(documentTitle), { name: 'robots', content: 'noindex, nofollow' }];
}
return [
@@ -49,18 +62,155 @@ export function meta({ params: { slug } }: Route.MetaArgs) {
];
}
export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) => {
if (slug.startsWith('qr_')) {
const document = await getDocumentByAccessToken({ token: slug });
type TQrShareErrorPayload = {
code: string;
correlationId: string;
};
if (!document) {
throw redirect('/');
const createQrShareErrorResponse = ({
status,
code,
correlationId,
headers,
}: {
status: number;
code: string;
correlationId: string;
headers?: HeadersInit;
}) => {
return new Response(
JSON.stringify({
code,
correlationId,
} satisfies TQrShareErrorPayload),
{
status,
headers: {
'Content-Type': 'application/json',
'X-Documenso-Error-Code': code,
...headers,
},
},
);
};
const parseQrShareErrorPayload = (value: unknown): TQrShareErrorPayload | null => {
if (!value || typeof value !== 'string') {
return null;
}
try {
const parsed = JSON.parse(value);
if (typeof parsed.code === 'string' && typeof parsed.correlationId === 'string') {
return parsed;
}
return {
document,
token: slug,
};
return null;
} catch {
return null;
}
};
export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) => {
if (slug.startsWith('qr_')) {
const correlationId = request.headers.get('x-request-id') ?? nanoid(12);
const requestMetadata = extractRequestMetadata(request);
const rateLimitResult = await qrShareViewRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: tokenFingerprint(slug),
});
if (rateLimitResult.isLimited) {
const retryAfter = String(Math.max(1, Math.ceil((rateLimitResult.reset.getTime() - Date.now()) / 1000)));
logger.warn({
msg: 'QR share access throttled',
documentId: null,
recipientId: null,
result: 'deny',
denyReasonCode: 'QR_VIEW_RATE_LIMITED',
correlationId,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
});
throw createQrShareErrorResponse({
status: 429,
code: 'QR_VIEW_RATE_LIMITED',
correlationId,
headers: {
'Retry-After': retryAfter,
'X-RateLimit-Limit': String(rateLimitResult.limit),
'X-RateLimit-Remaining': String(rateLimitResult.remaining),
'X-RateLimit-Reset': String(Math.ceil(rateLimitResult.reset.getTime() / 1000)),
},
});
}
try {
const document = await getDocumentByAccessToken({ token: slug });
logger.info({
msg: 'QR share access allowed',
documentId: document.id,
recipientId: null,
result: 'allow',
denyReasonCode: null,
correlationId,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
});
return {
document,
token: slug,
};
} catch (error) {
const appError = AppError.parseError(error);
const { status, code } = match(appError)
.when(
(e) => e.code === AppErrorCode.NOT_FOUND,
() => ({ status: 404, code: 'QR_VIEW_NOT_FOUND' }),
)
.when(
(e) => e.code === AppErrorCode.INVALID_REQUEST,
() => ({ status: 409, code: 'QR_VIEW_NOT_COMPLETED' }),
)
.when(
(e) => e.code === AppErrorCode.UNAUTHORIZED && e.statusCode === 403,
() => ({ status: 403, code: 'QR_VIEW_DISABLED' }),
)
.when(
(e) => e.code === AppErrorCode.UNAUTHORIZED,
() => ({ status: 401, code: 'QR_VIEW_UNAUTHORIZED' }),
)
.otherwise(() => ({ status: 500, code: 'QR_VIEW_INTERNAL_ERROR' }));
logger.warn({
msg: 'QR share access denied',
documentId: null,
recipientId: null,
result: 'deny',
denyReasonCode: code,
correlationId,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
});
// Unknown/invalid tokens keep the pre-existing public contract of
// redirecting home. Policy denials below render status-coded pages.
if (code === 'QR_VIEW_NOT_FOUND') {
throw redirect('/');
}
throw createQrShareErrorResponse({
status,
code,
correlationId,
});
}
}
const userAgent = request.headers.get('User-Agent') ?? '';
@@ -91,5 +241,45 @@ export default function SharePage() {
);
}
return <div></div>;
return null;
}
const qrShareErrorMessage = (code: string | undefined) =>
match(code)
.with('QR_VIEW_NOT_FOUND', () => <Trans>The shared document could not be found.</Trans>)
.with('QR_VIEW_NOT_COMPLETED', () => <Trans>This document is not fully completed yet.</Trans>)
.with('QR_VIEW_DISABLED', () => <Trans>Public completed-document access is currently disabled.</Trans>)
.with('QR_VIEW_UNAUTHORIZED', () => <Trans>You are not authorized to view this document.</Trans>)
.with('QR_VIEW_RATE_LIMITED', () => <Trans>Too many requests. Please try again shortly.</Trans>)
.otherwise(() => <Trans>Something went wrong while opening this shared view.</Trans>);
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const payload = isRouteErrorResponse(error) ? parseQrShareErrorPayload(error.data) : null;
return (
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<AlertCircle className="size-10 self-start text-destructive" />
<div className="flex flex-col gap-2">
<h2 className="font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Unable to Open Document</Trans>
</h2>
<p className="text-muted-foreground text-sm">{qrShareErrorMessage(payload?.code)}</p>
{payload?.correlationId && (
<p className="mt-4 font-medium text-muted-foreground text-xs uppercase tracking-wide">
<Trans>Support code: {payload.correlationId}</Trans>
</p>
)}
<Button className="mt-6 w-fit" asChild>
<Link to="/">
<Trans>Return Home</Trans>
</Link>
</Button>
</div>
</div>
</div>
);
}
+4 -2
View File
@@ -1,13 +1,15 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { i18n, type MessageDescriptor } from '@lingui/core';
export const appMetaTags = (title?: MessageDescriptor) => {
export const appMetaTags = (title?: MessageDescriptor | string) => {
const description =
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.';
const resolvedTitle = typeof title === 'string' ? title : title ? i18n._(title) : undefined;
return [
{
title: title ? `${i18n._(title)} - Documenso` : 'Documenso',
title: resolvedTitle ? `${resolvedTitle} - Documenso` : 'Documenso',
},
{
name: 'description',
@@ -32,10 +32,6 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
}))
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
@@ -81,10 +77,6 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
title: msg`Error`,
description: msg`The document was created but could not be sent to recipients.`,
}))
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
}))
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
title: msg`Error`,
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
+4 -4
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.18.1",
"@react-router/serve": "^7.18.1",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
+120 -70
View File
@@ -2,11 +2,16 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { qrShareViewRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { tokenFingerprint } from '@documenso/lib/universal/crypto';
import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import { DocumentStatus, type Prisma } from '@prisma/client';
import { type Context, Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
@@ -21,6 +26,101 @@ import {
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
import getEnvelopeItemPdfByTokenRoute from './routes/get-envelope-item-pdf-by-token';
const envelopeItemTokenInclude = {
envelope: {
include: {
team: {
include: {
teamGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
organisation: {
include: {
organisationGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
},
},
},
},
},
},
documentData: true,
} as const;
const maybeApplyQrRateLimit = async (c: Context<HonoEnv>, token: string) => {
let ip: string;
try {
ip = getIpAddress(c.req.raw);
} catch {
ip = 'unknown';
}
const result = await qrShareViewRateLimit.check({
ip,
identifier: tokenFingerprint(token),
});
return rateLimitResponse(c, result);
};
const getEnvelopeItemByToken = async (c: Context<HonoEnv>, token: string, envelopeItemId: string) => {
const isQrToken = token.startsWith('qr_');
if (isQrToken) {
const limited = await maybeApplyQrRateLimit(c, token);
if (limited) {
return { limited } as const;
}
}
const envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = isQrToken
? {
id: envelopeItemId,
envelope: {
qrToken: token,
status: DocumentStatus.COMPLETED,
},
}
: {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
};
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: envelopeItemTokenInclude,
});
if (!envelopeItem) {
return { error: c.json({ error: 'Envelope item not found' }, 404) } as const;
}
if (isQrToken && !isPublicDocumentAccessEnabled(envelopeItem.envelope.team)) {
return {
error: c.json({ error: 'Public completed-document access is disabled for this document' }, 403),
} as const;
}
if (!envelopeItem.documentData) {
return { error: c.json({ error: 'Document data not found' }, 404) } as const;
}
return { envelopeItem, isQrToken } as const;
};
export const filesRoute = new Hono<HonoEnv>()
/**
* Uploads a document file to the appropriate storage location and creates
@@ -239,46 +339,20 @@ export const filesRoute = new Hono<HonoEnv>()
async (c) => {
const { token, envelopeItemId } = c.req.valid('param');
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
};
const result = await getEnvelopeItemByToken(c, token, envelopeItemId);
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeItemId,
envelope: {
qrToken: token,
},
};
if ('limited' in result) {
return result.limited;
}
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: {
envelope: true,
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
if ('error' in result) {
return result.error;
}
return await handleEnvelopeItemFileRequest({
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
title: result.envelopeItem.title,
status: result.envelopeItem.envelope.status,
documentData: result.envelopeItem.documentData!,
version: 'signed',
isDownload: false,
context: c,
@@ -291,47 +365,23 @@ export const filesRoute = new Hono<HonoEnv>()
async (c) => {
const { token, envelopeItemId, version } = c.req.valid('param');
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
};
const result = await getEnvelopeItemByToken(c, token, envelopeItemId);
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeItemId,
envelope: {
qrToken: token,
},
};
if ('limited' in result) {
return result.limited;
}
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: {
envelope: true,
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
if ('error' in result) {
return result.error;
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
const effectiveVersion = result.isQrToken ? 'signed' : version;
return await handleEnvelopeItemFileRequest({
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
version,
title: result.envelopeItem.title,
status: result.envelopeItem.envelope.status,
documentData: result.envelopeItem.documentData!,
version: effectiveVersion,
isDownload: true,
context: c,
});
+1264 -2048
View File
File diff suppressed because it is too large Load Diff
@@ -1,76 +0,0 @@
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor';
const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template';
/**
* Place a field on the PDF canvas in the envelope editor.
*/
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 });
};
/**
* Seed a V2 direct template and open it in the native template editor.
*
* Only the native template editor is covered here: direct links only exist
* for templates and are not part of the embedded editor surfaces.
*/
const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: `E2E Direct Template Validation ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: options.createDirectRecipientSignatureField,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
return { user, team, template };
};
test.describe('template editor', () => {
test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
await expect(page.getByText('are missing a signature field')).toBeVisible();
});
test('does not show the warning when all signers have signature fields', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true });
// Wait for the editor to render before asserting the banner is absent.
await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible();
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible();
});
test('warning disappears after placing a signature field', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
// Place a signature field for the direct recipient (auto-selected single recipient).
await clickEnvelopeEditorStep(page, 'addFields');
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 });
// The banner clears once the field is autosaved and the envelope state updates.
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 });
});
});
@@ -6,7 +6,6 @@ import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { signSignaturePad } from '../fixtures/signature';
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
const { user, team } = await seedUser();
@@ -74,19 +73,8 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
await expect(page.locator('body')).toContainText('public-direct-template-title');
await expect(page.locator('body')).toContainText('public-direct-template-description');
const directSignatureField = directTemplate.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await page.getByRole('link', { name: 'Sign' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
@@ -197,18 +197,7 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled();
const directSignatureField = directTemplateWithAuth.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await page.getByRole('button', { name: 'Continue' }).click();
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
@@ -246,37 +235,6 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
const directSignatureField = directTemplateWithAuth.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
// Wait for the PDF and the Konva canvas overlay to be ready.
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
await signSignaturePad(page);
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x =
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
@@ -308,16 +266,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
const directSignatureField = template.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
@@ -351,13 +299,19 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
},
});
// The seeded direct template already includes a signature field for the direct recipient.
const directSignatureField = template.fields[0];
const directTemplateRecipient = template.recipients[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
@@ -459,13 +413,19 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
},
});
// The seeded direct template already includes a signature field for the direct recipient.
const directSignatureField = template.fields[0];
const directTemplateRecipient = template.recipients[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
@@ -561,48 +521,3 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
});
test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({
page,
}) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: 'V1 invalid direct template',
userId: user.id,
teamId: team.id,
createDirectRecipientSignatureField: false,
});
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
// The signing flow must not render.
await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible();
});
test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({
page,
}) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: 'V2 invalid direct template',
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: false,
});
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
// The signing flow (PDF canvas) must not render.
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible();
});
@@ -1,101 +0,0 @@
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: options.envelopeId },
});
return await prisma.field.create({
data: {
envelopeId: options.envelopeId,
envelopeItemId: envelopeItem.id,
recipientId: options.recipientId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 10,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
},
});
};
test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({
page,
}) => {
const { user, team } = await seedUser();
// seedTemplate creates one SIGNER recipient and no fields.
await seedTemplate({
title: 'Template missing signature fields',
userId: user.id,
teamId: team.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
// Enable distribution so the document is sent on creation.
await page.locator('#distributeDocument').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await expectToastTextToBeVisible(page, 'Missing signature fields');
await expectToastTextToBeVisible(
page,
'The document could not be sent because some signers do not have a signature field',
);
});
test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedTemplate({
title: 'Template with signature fields',
userId: user.id,
teamId: team.id,
});
await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: template.recipients[0].id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
await page.locator('#distributeDocument').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
const envelopeId = page.url().split('/').pop()?.split('?')[0];
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: envelopeId },
});
expect(envelope.status).toBe(DocumentStatus.PENDING);
});
-9
View File
@@ -36,13 +36,6 @@ export enum AppErrorCode {
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
* signer has no signature field.
*/
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
/**
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
* for the recovery taxonomy.
@@ -91,7 +84,6 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -299,7 +291,6 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
@@ -1,3 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
@@ -9,25 +11,41 @@ export type GetDocumentByAccessTokenOptions = {
export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => {
if (!token) {
throw new Error('Missing token');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Missing QR access token',
statusCode: 401,
});
}
const result = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
status: DocumentStatus.COMPLETED,
qrToken: token,
},
// Do not provide extra information that is not needed.
select: {
id: true,
secondaryId: true,
status: true,
internalVersion: true,
title: true,
completedAt: true,
team: {
select: {
url: true,
organisation: {
select: {
organisationGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
},
},
teamGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
},
},
envelopeItems: {
@@ -56,17 +74,33 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
});
if (!result) {
return null;
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'QR token not found',
statusCode: 404,
});
}
if (result.envelopeItems.length === 0) {
throw new Error('Completed envelope has no items');
if (result.status !== DocumentStatus.COMPLETED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document is not fully completed',
statusCode: 409,
});
}
const firstDocumentData = result.envelopeItems[0].documentData;
if (!isPublicDocumentAccessEnabled(result.team)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Public completed-document access is disabled for this document',
statusCode: 403,
});
}
if (!firstDocumentData) {
throw new Error('Missing document data');
const firstEnvelopeItem = result.envelopeItems[0];
if (!firstEnvelopeItem?.documentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Missing document data for QR token',
statusCode: 404,
});
}
return {
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
.join(', ');
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
});
}
@@ -5,7 +5,6 @@ import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@@ -126,17 +125,6 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
@@ -111,3 +111,10 @@ export const fileUploadRateLimit = createRateLimit({
max: 20,
window: '1m',
});
export const qrShareViewRateLimit = createRateLimit({
action: 'app.qr-share-view',
max: 20,
globalMax: 120,
window: '1m',
});
@@ -40,7 +40,6 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { incrementDocumentId } from '../envelope/increment-id';
@@ -173,17 +172,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
recipients,
recipients.flatMap((recipient) => recipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
+4
View File
@@ -31,4 +31,8 @@ export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
return chacha.decrypt(dataAsBytes);
};
export const tokenFingerprint = (token: string): string => {
return bytesToHex(sha256(token)).slice(0, 16);
};
export { sha256 };
+21
View File
@@ -0,0 +1,21 @@
type PublicAccessTeam = {
teamGlobalSettings?: {
allowPublicCompletedDocumentAccess: boolean | null;
} | null;
organisation: {
organisationGlobalSettings: {
allowPublicCompletedDocumentAccess: boolean;
};
};
} | null;
export const isPublicDocumentAccessEnabled = (team: PublicAccessTeam): boolean => {
if (!team) {
return true;
}
return (
team.teamGlobalSettings?.allowPublicCompletedDocumentAccess ??
team.organisation.organisationGlobalSettings.allowPublicCompletedDocumentAccess
);
};
+1
View File
@@ -114,6 +114,7 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
includeSenderDetails: true,
includeSigningCertificate: true,
allowPublicCompletedDocumentAccess: true,
includeAuditLog: false,
typedSignatureEnabled: true,
+1
View File
@@ -181,6 +181,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
includeSenderDetails: null,
includeSigningCertificate: null,
allowPublicCompletedDocumentAccess: null,
includeAuditLog: null,
typedSignatureEnabled: null,
@@ -0,0 +1,5 @@
ALTER TABLE "OrganisationGlobalSettings"
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "TeamGlobalSettings"
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN;
@@ -0,0 +1 @@
CREATE INDEX "Envelope_qrToken_idx" ON "Envelope"("qrToken");
+3
View File
@@ -492,6 +492,7 @@ model Envelope {
@@index([teamId])
@@index([folderId])
@@index([createdAt])
@@index([qrToken])
}
model EnvelopeItem {
@@ -961,6 +962,7 @@ model OrganisationGlobalSettings {
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
allowPublicCompletedDocumentAccess Boolean @default(true)
includeAuditLog Boolean @default(false)
documentTimezone String? // Nullable to allow using local timezones if not set.
documentDateFormat String @default("yyyy-MM-dd hh:mm a")
@@ -1007,6 +1009,7 @@ model TeamGlobalSettings {
includeSenderDetails Boolean?
includeSigningCertificate Boolean?
allowPublicCompletedDocumentAccess Boolean?
includeAuditLog Boolean?
typedSignatureEnabled Boolean?
+3 -3
View File
@@ -361,9 +361,9 @@ export const seedAlignmentTestDocument = async ({
const { id, recipients, envelopeItems } = createdEnvelope;
if (isDirectTemplate) {
const directTemplateRecipient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
const directTemplateRecpient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
if (!directTemplateRecipient) {
if (!directTemplateRecpient) {
throw new Error('Need to create a direct template recipient');
}
@@ -372,7 +372,7 @@ export const seedAlignmentTestDocument = async ({
envelopeId: id,
enabled: true,
token: directTemplateToken ?? Math.random().toString(),
directTemplateRecipientId: directTemplateRecipient.id,
directTemplateRecipientId: directTemplateRecpient.id,
},
});
}
+3 -35
View File
@@ -6,7 +6,6 @@ import {
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates';
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { SignatureLevel } from '@documenso/lib/types/signature-level';
import { prefixedId } from '@documenso/lib/universal/id';
@@ -16,7 +15,6 @@ import {
DocumentDataType,
DocumentSource,
EnvelopeType,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
@@ -31,11 +29,6 @@ type SeedTemplateOptions = {
teamId: number;
internalVersion?: 1 | 2;
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
/**
* Only used by seedDirectTemplate. Creates a signature field for the direct
* recipient so the seeded direct template is valid. Defaults to true.
*/
createDirectRecipientSignatureField?: boolean;
};
type CreateTemplateOptions = {
@@ -205,11 +198,11 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
},
});
const directTemplateRecipient = template.recipients.find(
const directTemplateRecpient = template.recipients.find(
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
);
if (!directTemplateRecipient) {
if (!directTemplateRecpient) {
throw new Error('Need to create a direct template recipient');
}
@@ -218,35 +211,10 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
envelopeId: template.id,
enabled: true,
token: Math.random().toString(),
directTemplateRecipientId: directTemplateRecipient.id,
directTemplateRecipientId: directTemplateRecpient.id,
},
});
const { createDirectRecipientSignatureField = true } = options;
if (createDirectRecipientSignatureField) {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: template.id },
});
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: envelopeItem.id,
recipientId: directTemplateRecipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 10,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
},
});
}
return await prisma.envelope.findFirstOrThrow({
where: {
id: template.id,
@@ -1,8 +1,9 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getOrganisationTemplateWhereInput } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { isPublicDocumentAccessEnabled } from '@documenso/lib/universal/document-access';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { maybeAuthenticatedProcedure } from '../trpc';
import {
@@ -56,15 +57,13 @@ export const getEnvelopeItemsByTokenRoute = maybeAuthenticatedProcedure
});
const handleGetEnvelopeItemsByToken = async ({ envelopeId, token }: { envelopeId: string; token: string }) => {
const isQrToken = token.startsWith('qr_');
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT, // You cannot get template envelope items by token.
recipients: {
some: {
token,
},
},
type: EnvelopeType.DOCUMENT,
...(isQrToken ? { qrToken: token, status: DocumentStatus.COMPLETED } : { recipients: { some: { token } } }),
},
include: {
envelopeItems: {
@@ -72,6 +71,20 @@ const handleGetEnvelopeItemsByToken = async ({ envelopeId, token }: { envelopeId
documentData: true,
},
},
team: {
include: {
teamGlobalSettings: {
select: { allowPublicCompletedDocumentAccess: true },
},
organisation: {
include: {
organisationGlobalSettings: {
select: { allowPublicCompletedDocumentAccess: true },
},
},
},
},
},
},
});
@@ -81,6 +94,12 @@ const handleGetEnvelopeItemsByToken = async ({ envelopeId, token }: { envelopeId
});
}
if (isQrToken && !isPublicDocumentAccessEnabled(envelope.team)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Public completed-document access is disabled',
});
}
return {
envelopeItems: envelope.envelopeItems,
};
@@ -33,6 +33,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -162,6 +163,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -21,6 +21,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
documentDateFormat: ZDocumentMetaDateFormatSchema.optional(),
includeSenderDetails: z.boolean().optional(),
includeSigningCertificate: z.boolean().optional(),
allowPublicCompletedDocumentAccess: z.boolean().optional(),
includeAuditLog: z.boolean().optional(),
typedSignatureEnabled: z.boolean().optional(),
uploadSignatureEnabled: z.boolean().optional(),
@@ -32,6 +32,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -165,6 +166,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -25,6 +25,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
documentDateFormat: ZDocumentMetaDateFormatSchema.nullish(),
includeSenderDetails: z.boolean().nullish(),
includeSigningCertificate: z.boolean().nullish(),
allowPublicCompletedDocumentAccess: z.boolean().nullish(),
includeAuditLog: z.boolean().nullish(),
typedSignatureEnabled: z.boolean().nullish(),
uploadSignatureEnabled: z.boolean().nullish(),