Compare commits

...

9 Commits

Author SHA1 Message Date
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
25 changed files with 855 additions and 170 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`
@@ -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,
@@ -471,6 +475,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"
@@ -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);
@@ -66,6 +66,10 @@ export const EnvelopeRendererFileSelector = ({
}: EnvelopeRendererFileSelectorProps) => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
if (envelopeItems.length <= 1) {
return null;
}
return (
<div className={cn('scrollbar-hidden flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
{envelopeItems.map((doc, i) => (
@@ -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,
@@ -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}
+197 -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,149 @@ 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,
});
throw createQrShareErrorResponse({
status,
code,
correlationId,
});
}
}
const userAgent = request.headers.get('User-Agent') ?? '';
@@ -91,5 +235,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',
+120 -70
View File
@@ -2,12 +2,17 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { 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 { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
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 } from './files.helpers';
@@ -24,6 +29,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
@@ -249,46 +349,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,
@@ -301,47 +375,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,
});
@@ -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 {
@@ -97,3 +97,10 @@ export const fileUploadRateLimit = createRateLimit({
max: 20,
window: '1m',
});
export const qrShareViewRateLimit = createRateLimit({
action: 'app.qr-share-view',
max: 20,
globalMax: 120,
window: '1m',
});
+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
@@ -443,6 +443,7 @@ model Envelope {
@@index([teamId])
@@index([folderId])
@@index([createdAt])
@@index([qrToken])
}
model EnvelopeItem {
@@ -840,6 +841,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")
@@ -886,6 +888,7 @@ model TeamGlobalSettings {
includeSenderDetails Boolean?
includeSigningCertificate Boolean?
allowPublicCompletedDocumentAccess Boolean?
includeAuditLog Boolean?
typedSignatureEnabled Boolean?
@@ -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,
@@ -163,6 +164,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,
@@ -166,6 +167,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(),