mirror of
https://github.com/documenso/documenso.git
synced 2026-06-26 06:12:07 +10:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ca8ad907e | |||
| f7b3554b2a | |||
| 6ff8cd7cb2 | |||
| 138d663c25 | |||
| 9194884fbe | |||
| 9de87ca906 | |||
| 7163800d36 | |||
| bd56929db1 |
-199
@@ -1,199 +0,0 @@
|
||||
---
|
||||
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`
|
||||
@@ -0,0 +1,289 @@
|
||||
---
|
||||
date: 2026-02-02
|
||||
title: Support For External 2fa Codes
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Enable organizations to enforce a second factor for document signing while keeping delivery fully external (for example customer-owned SMS), with strong recipient/session binding and auditable controls.
|
||||
|
||||
## Problem Context
|
||||
|
||||
- Many legacy organizations still rely on SMS for second-factor delivery.
|
||||
- Their users cannot realistically migrate to authenticator apps or passkeys yet.
|
||||
- Operating first-party SMS infrastructure in Documenso is costly, risky, and outside core scope.
|
||||
- Customers need an API-first integration path that fits existing notification infrastructure and compliance controls.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Introduce external 2FA codes for signing:
|
||||
|
||||
1. A trusted backend service requests a one-time signing token via API.
|
||||
2. The customer delivers that token to the signer through their own existing channel (for example SMS).
|
||||
3. The signer enters the token in the signing flow.
|
||||
4. Documenso validates the submitted token, then issues a short-lived session-bound verification proof.
|
||||
5. Signature completion is allowed only when the proof is present and valid for that recipient signing session.
|
||||
|
||||
## Decisions Captured In Interview
|
||||
|
||||
- Enforcement scope: template-level default with per-recipient override.
|
||||
- Issuer trust boundary: scoped machine API keys with explicit permission.
|
||||
- Token lifecycle: newest token immediately revokes prior active token for same recipient/document.
|
||||
- Brute-force control: token-scoped hard attempt cap.
|
||||
- Security defaults: TTL 10 minutes, max 5 attempts.
|
||||
- Verification unlock: session-bound proof (not global recipient unlock).
|
||||
- Issuance contract: idempotent-ish reissue behavior with explicit structured denial reasons.
|
||||
- Audit privacy: never log token/code material; log identifiers and reason codes only.
|
||||
- Missing token at signing time: block with actionable state.
|
||||
- Rollback behavior: feature-flag off for new sessions only.
|
||||
- Resend/recovery in v1: support-owned reissue guidance only (no signer self-serve trigger).
|
||||
- Workspace policy controls in v1: no per-workspace TTL/attempt overrides.
|
||||
- Session proof TTL in v1: 10 minutes.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- API endpoint to issue short-lived signing 2FA tokens for eligible recipients.
|
||||
- Secure storage/verification mechanism (hashed token + expiry + attempt tracking).
|
||||
- Signing UI step to collect token before signature submission.
|
||||
- Standard operating flow: token is generated via API and entered by the recipient in the UI.
|
||||
- Verification endpoint/path integrated into signing completion checks.
|
||||
- Audit logging for token issuance and verification attempts.
|
||||
- Template policy defaults with per-recipient override support.
|
||||
- Session-bound verification proof issuance after successful code validation.
|
||||
- Feature-flagged rollout controls at workspace/organization scope.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Native SMS sending/providers inside Documenso.
|
||||
- New authenticator/passkey implementation.
|
||||
- Cross-channel delivery guarantees (owned by customer infrastructure).
|
||||
- UI-only token generation as the primary flow in this phase.
|
||||
- Fully configurable TTL/attempt policy per workspace in v1.
|
||||
- Customer callback/webhook resend orchestration in v1.
|
||||
- Signer-triggered self-serve reissue controls in v1.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- Token is recipient-bound and document/session-bound.
|
||||
- Token cannot be shared across recipients or recipient roles.
|
||||
- A recipient token only authorizes signature actions for that same recipient identity.
|
||||
- If the same human is represented by multiple recipient records, each recipient record still requires its own token.
|
||||
- Token has strict TTL of 10 minutes and single-use semantics.
|
||||
- Token verification fails on expiry, mismatch, too many attempts, or reuse.
|
||||
- Endpoint access is restricted to scoped API clients with explicit issuance permission.
|
||||
- Clear, localized user errors for invalid/expired tokens.
|
||||
- Max 5 verification attempts per token; on cap reached, token becomes unusable and signer must use a newly issued token.
|
||||
- Issuing a new token revokes any existing active token for the same recipient/document pair.
|
||||
- Successful verification creates a short-lived session-bound proof; only that session can complete signature.
|
||||
- If 2FA is required but no valid token has been issued yet, signing must be blocked with actionable guidance.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- Verification and consumption path must be atomic and race-safe under concurrent requests.
|
||||
- Error responses must use stable machine-readable reason codes for customer integrations.
|
||||
- p95 verification latency should remain within existing signing guardrail budget (target: <= 300 ms server-side).
|
||||
- Security controls and audit logging must not expose token/code values in logs, traces, or analytics payloads.
|
||||
|
||||
## Policy Model
|
||||
|
||||
- Default requirement is configured at template/workflow level.
|
||||
- Sender can override requirement per recipient before send.
|
||||
- Effective policy is materialized on recipient/document at send time to avoid template drift during in-flight signing.
|
||||
- Feature flag gates enforcement by workspace/organization for rollout and rollback.
|
||||
|
||||
## API Contract
|
||||
|
||||
### Token Issuance Endpoint
|
||||
|
||||
- Auth: scoped API key with dedicated permission (for example `signing_2fa:issue`).
|
||||
- Input: recipient/document context and optional idempotency metadata.
|
||||
- Behavior:
|
||||
- Eligible recipient: always issues a fresh token and revokes prior active token.
|
||||
- Ineligible/forbidden state: returns structured 4xx with explicit reason code.
|
||||
- Never returns previously generated plaintext token; token is visible exactly once at issuance.
|
||||
- Output:
|
||||
- Plaintext token (single response only).
|
||||
- Metadata for integration handling (expiresAt, ttlSeconds, attemptLimit, issuedAt).
|
||||
|
||||
### Verification Endpoint
|
||||
|
||||
- Input: token submission from signing UI bound to current signing session context.
|
||||
- Behavior:
|
||||
- Valid token: atomically consumes token and issues session-bound verification proof.
|
||||
- Invalid token: increments attempts and returns reason code.
|
||||
- Expired/revoked/consumed/capped: returns denial reason without revealing sensitive internals.
|
||||
- Output:
|
||||
- Success: verification state for current session.
|
||||
- Failure: localized user-safe message + machine reason code.
|
||||
|
||||
### Resend/Reissue Behavior (v1)
|
||||
|
||||
- No signer-triggered callback/webhook or self-serve reissue endpoint in v1.
|
||||
- If token is missing/expired/revoked/capped, signer sees actionable guidance to contact sender/support.
|
||||
- Reissue remains an API-key-initiated operation from trusted customer backend only.
|
||||
|
||||
### Suggested Reason Codes
|
||||
|
||||
- `TWO_FA_NOT_REQUIRED`
|
||||
- `TWO_FA_NOT_ISSUED`
|
||||
- `TWO_FA_TOKEN_INVALID`
|
||||
- `TWO_FA_TOKEN_EXPIRED`
|
||||
- `TWO_FA_TOKEN_REVOKED`
|
||||
- `TWO_FA_TOKEN_CONSUMED`
|
||||
- `TWO_FA_ATTEMPT_LIMIT_REACHED`
|
||||
- `TWO_FA_ISSUER_FORBIDDEN`
|
||||
- `TWO_FA_RECIPIENT_INELIGIBLE`
|
||||
|
||||
## Data Model
|
||||
|
||||
Create `signing_two_factor_tokens` (name indicative):
|
||||
|
||||
- `id`
|
||||
- `recipientId`
|
||||
- `documentId`
|
||||
- `tokenHash`
|
||||
- `tokenSalt` (or use KDF settings sufficient to avoid raw-secret recovery)
|
||||
- `expiresAt`
|
||||
- `consumedAt` nullable
|
||||
- `revokedAt` nullable
|
||||
- `attempts` default 0
|
||||
- `attemptLimit` default 5
|
||||
- `issuedByApiKeyId` (or actor reference)
|
||||
- `createdAt`
|
||||
|
||||
Optional companion table/entity for session proof:
|
||||
|
||||
- `signing_session_2fa_proofs`
|
||||
- `sessionId`
|
||||
- `recipientId`
|
||||
- `documentId`
|
||||
- `verifiedAt`
|
||||
- `expiresAt`
|
||||
|
||||
Constraints and indexes:
|
||||
|
||||
- Index on (`recipientId`, `documentId`, `expiresAt`).
|
||||
- At most one active token per (`recipientId`, `documentId`) enforced by transactional revoke-on-issue.
|
||||
- Guard against lost-update on attempts and consume via row lock or atomic update conditions.
|
||||
|
||||
## Signing UX
|
||||
|
||||
- Insert 2FA code step before signature commit when effective policy requires it.
|
||||
- UX states:
|
||||
- Waiting for code input.
|
||||
- Invalid code (remaining attempts shown where safe).
|
||||
- Expired/revoked/attempt cap reached with clear next-step copy.
|
||||
- Not issued yet state with actionable guidance.
|
||||
- Recovery copy in v1 must direct signer to sender/support (no in-product resend action).
|
||||
- Localization required for all user-facing errors.
|
||||
- Accessibility: input labeling, error announcement, keyboard submission, mobile-friendly numeric entry.
|
||||
- Session-bound proof behavior must be transparent to user (no global unlock across devices/tabs).
|
||||
|
||||
## Security Requirements
|
||||
|
||||
- Never persist plaintext token; store salted hash only.
|
||||
- Rate-limit issuance and verification attempts.
|
||||
- Invalidate previous active token immediately when a new token is issued.
|
||||
- Emit security/audit events with actor, recipient, document, timestamp, and reason codes.
|
||||
- Prevent token leakage in logs, telemetry, and error payloads.
|
||||
- Use constant-time comparison and hardened random token generation.
|
||||
- Enforce short proof lifetime for verified session to reduce replay window.
|
||||
- Set proof TTL to 10 minutes in v1.
|
||||
|
||||
## Observability And Audit
|
||||
|
||||
Emit events for:
|
||||
|
||||
- `2fa_token_issued`
|
||||
- `2fa_token_issue_denied`
|
||||
- `2fa_token_verify_succeeded`
|
||||
- `2fa_token_verify_failed`
|
||||
- `2fa_token_consumed`
|
||||
- `2fa_token_revoked`
|
||||
|
||||
Event fields:
|
||||
|
||||
- `workspaceId`, `documentId`, `recipientId`
|
||||
- `actorType` (api_key, signer_session, system)
|
||||
- `actorId` (where applicable)
|
||||
- `reasonCode`
|
||||
- `ipHash`, `userAgentHash` (if available)
|
||||
- `timestamp`
|
||||
|
||||
Metrics and alerts:
|
||||
|
||||
- Issuance success/failure rates.
|
||||
- Verification success/failure rate split by reason code.
|
||||
- Attempt-limit-hit rate.
|
||||
- p95 verification latency.
|
||||
- Alert on unusual spikes in invalid attempts per recipient/document/workspace.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Domain model
|
||||
- Add signing 2FA token entity/table and session-proof persistence.
|
||||
2. Token issuance API
|
||||
- Add authenticated route for scoped API keys; issue fresh token, revoke prior active.
|
||||
3. Verification logic
|
||||
- Validate token state, increment attempts atomically, consume on success, mint session proof.
|
||||
4. Signing flow integration
|
||||
- Add UI token prompt and backend guard requiring valid session proof.
|
||||
5. Observability
|
||||
- Add reason-coded events and dashboards/alerts.
|
||||
6. Controls
|
||||
- Add rate limits, attempt cap (5), revoke-on-reissue, and feature flag checks.
|
||||
7. Testing
|
||||
- Unit tests for generation/verification edge cases.
|
||||
- Integration tests for API and signing flow.
|
||||
- Concurrency tests for double-submit and parallel verification.
|
||||
|
||||
## Testing Matrix
|
||||
|
||||
- Token issuance for eligible/ineligible recipients.
|
||||
- Reissue revokes previous token immediately.
|
||||
- Verification success path creates session-bound proof.
|
||||
- Verification fails on mismatch, expiry, revoked, consumed, cap reached.
|
||||
- Attempt counter increments correctly under concurrent requests.
|
||||
- Signature blocked when proof absent or expired.
|
||||
- Recipient A token rejected for recipient B (including same human/multiple recipient records).
|
||||
- Feature flag off: new sessions bypass external 2FA requirement.
|
||||
- Audit events emitted with expected reason codes and no token material.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- External system can request a token for an eligible signer through API.
|
||||
- Signer cannot complete signing without valid token when policy requires 2FA.
|
||||
- A token issued for recipient A is always rejected for recipient B, including when both recipients map to the same underlying person.
|
||||
- Valid token allows signing exactly once within TTL.
|
||||
- Expired/reused/invalid tokens are rejected with clear errors.
|
||||
- No Documenso-owned SMS infrastructure is introduced.
|
||||
- Audit trail captures issuance and verification outcomes.
|
||||
- Default policy can be set at template level with per-recipient override at send time.
|
||||
- New token issuance revokes prior active token for same recipient/document.
|
||||
- Max 5 failed attempts per token is enforced.
|
||||
- Successful verification unlocks only the active signing session.
|
||||
- If no token has been issued yet, signer is blocked with actionable guidance.
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
- Ship behind feature flag (workspace-level or organization-level).
|
||||
- Enable first for pilot customers in regulated domains.
|
||||
- Monitor verification failure rates and support feedback.
|
||||
- Gradually expand availability once stable.
|
||||
- Rollback path: disable flag for new sessions only; preserve already verified in-flight sessions.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- Brute-force attempts -> enforce attempt caps, lockouts, and rate limits.
|
||||
- Delivery delays in customer SMS systems -> allow controlled token re-issue.
|
||||
- Support burden from expiry confusion -> clear UX copy and resend guidance.
|
||||
- Concurrency race on consume/attempt updates -> use transactional atomic updates and dedicated tests.
|
||||
- Misconfigured API clients -> explicit permission scopes and structured denial reasons.
|
||||
- Forensic gaps vs privacy over-collection -> reason-coded audits with hashed network metadata only.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None for v1 scope.
|
||||
- v1.1 exploration candidate: customer-controlled signer-triggered callback/reissue flow with abuse protections.
|
||||
@@ -103,7 +103,6 @@ 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;
|
||||
@@ -161,41 +160,41 @@ export const EnvelopeDownloadDialog = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex w-full flex-col gap-4 overflow-hidden">
|
||||
{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" />
|
||||
{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" />
|
||||
|
||||
<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 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>
|
||||
</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>
|
||||
<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="flex flex-shrink-0 items-center gap-2">
|
||||
{!isQrToken && (
|
||||
<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">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -208,26 +207,24 @@ 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>
|
||||
)}
|
||||
{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>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -58,7 +58,6 @@ export type TDocumentPreferencesFormSchema = {
|
||||
documentDateFormat: TDocumentMetaDateFormat | null;
|
||||
includeSenderDetails: boolean | null;
|
||||
includeSigningCertificate: boolean | null;
|
||||
allowPublicCompletedDocumentAccess: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
defaultRecipients: TDefaultRecipients | null;
|
||||
@@ -76,7 +75,6 @@ type SettingsSubset = Pick<
|
||||
| 'documentDateFormat'
|
||||
| 'includeSenderDetails'
|
||||
| 'includeSigningCertificate'
|
||||
| 'allowPublicCompletedDocumentAccess'
|
||||
| 'includeAuditLog'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
@@ -118,7 +116,6 @@ 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,
|
||||
@@ -139,7 +136,6 @@ 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,
|
||||
@@ -475,58 +471,6 @@ 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"
|
||||
|
||||
+13
-8
@@ -13,6 +13,7 @@ import { match, P } from 'ts-pattern';
|
||||
|
||||
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
|
||||
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
|
||||
import { DocumentSigningAuthExternal2FA } from './document-signing-auth-external-2fa';
|
||||
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
|
||||
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
@@ -58,15 +59,8 @@ export const DocumentSigningAuthDialog = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset selected auth type when dialog closes
|
||||
if (!value) {
|
||||
setSelectedAuthType(() => {
|
||||
if (validAuthTypes.length === 1) {
|
||||
return validAuthTypes[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
setSelectedAuthType(validAuthTypes.length === 1 ? validAuthTypes[0] : null);
|
||||
}
|
||||
|
||||
onOpenChange(value);
|
||||
@@ -123,6 +117,7 @@ export const DocumentSigningAuthDialog = ({
|
||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
|
||||
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
|
||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
|
||||
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => <Trans>Verification code</Trans>)
|
||||
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
@@ -132,6 +127,9 @@ export const DocumentSigningAuthDialog = ({
|
||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
|
||||
.with(DocumentAuth.PASSKEY, () => <Trans>Use your passkey for authentication</Trans>)
|
||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>Enter your 2FA code</Trans>)
|
||||
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
|
||||
<Trans>Enter the verification code provided to you</Trans>
|
||||
))
|
||||
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
@@ -169,6 +167,13 @@ export const DocumentSigningAuthDialog = ({
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, () => (
|
||||
<DocumentSigningAuthExternal2FA
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SIGNING_2FA_VERIFY_REASON_CODES } from '@documenso/lib/constants/document-auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningAuthExternal2FAProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const ZExternal2FAFormSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.length(6, { message: 'Code must be exactly 6 digits' })
|
||||
.regex(/^\d{6}$/, { message: 'Code must contain only digits' }),
|
||||
});
|
||||
|
||||
type TExternal2FAFormSchema = z.infer<typeof ZExternal2FAFormSchema>;
|
||||
|
||||
export const DocumentSigningAuthExternal2FA = ({
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentSigningAuthExternal2FAProps) => {
|
||||
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const statusQuery = trpc.envelope.signing2fa.getStatus.useQuery(
|
||||
{ token: recipient.token },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const verifyMutation = trpc.envelope.signing2fa.verify.useMutation();
|
||||
|
||||
const form = useForm<TExternal2FAFormSchema>({
|
||||
resolver: zodResolver(ZExternal2FAFormSchema),
|
||||
defaultValues: {
|
||||
code: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ code }: TExternal2FAFormSchema) => {
|
||||
try {
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
setFormError(null);
|
||||
|
||||
await verifyMutation.mutateAsync({
|
||||
token: recipient.token,
|
||||
code,
|
||||
});
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED) {
|
||||
setFormError('Too many failed attempts. Please request a new code.');
|
||||
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED) {
|
||||
setFormError('The code has expired. Please request a new code.');
|
||||
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED) {
|
||||
setFormError('No code has been issued yet. Please contact the document sender.');
|
||||
} else {
|
||||
setFormError('Invalid code. Please try again.');
|
||||
}
|
||||
|
||||
await statusQuery.refetch();
|
||||
form.reset({ code: '' });
|
||||
} finally {
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ code: '' });
|
||||
setFormError(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const attemptsRemaining = statusQuery.data?.attemptsRemaining ?? null;
|
||||
const hasActiveToken = statusQuery.data?.hasActiveToken ?? false;
|
||||
const hasValidProof = statusQuery.data?.hasValidProof ?? false;
|
||||
|
||||
if (hasValidProof) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<Trans>Your identity has already been verified. You can proceed to sign.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasActiveToken && !statusQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>Verification code required</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
A verification code is required to sign this document. Please contact the document
|
||||
sender to request your code.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Enter the 6-digit verification code that was provided to you.</Trans>
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Verification code</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{attemptsRemaining !== null && attemptsRemaining > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>{attemptsRemaining} attempts remaining</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
<Trans>Verification failed</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{formError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
<Trans>Verify</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
+7
-7
@@ -61,13 +61,13 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export interface DocumentSigningAuthProviderProps {
|
||||
export type DocumentSigningAuthProviderProps = {
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: SigningAuthRecipient;
|
||||
isDirectTemplate?: boolean;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
export const DocumentSigningAuthProvider = ({
|
||||
documentAuthOptions: initialDocumentAuthOptions,
|
||||
@@ -169,12 +169,12 @@ export const DocumentSigningAuthProvider = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [passkeyData.passkeys]);
|
||||
|
||||
// Assume that a user must be logged in for any auth requirements.
|
||||
const authMethodsRequiringLogin = derivedRecipientActionAuth?.filter(
|
||||
(method) => method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
);
|
||||
|
||||
const isAuthRedirectRequired = Boolean(
|
||||
derivedRecipientActionAuth &&
|
||||
derivedRecipientActionAuth.length > 0 &&
|
||||
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
|
||||
user?.email !== recipient.email,
|
||||
authMethodsRequiringLogin && authMethodsRequiringLogin.length > 0 && user?.email !== recipient.email,
|
||||
);
|
||||
|
||||
const refetchPasskeys = async () => {
|
||||
|
||||
@@ -106,8 +106,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
}))
|
||||
.with(undefined, () => undefined)
|
||||
.with(
|
||||
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
|
||||
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
|
||||
P.union(
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
DocumentAuth.PASSWORD,
|
||||
),
|
||||
() => 'NOT_SUPPORTED' as const,
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -50,9 +49,9 @@ export const DocumentCertificateQRView = ({
|
||||
completedDate,
|
||||
token,
|
||||
}: DocumentCertificateQRViewProps) => {
|
||||
const { sessionData } = useOptionalSession();
|
||||
|
||||
const { data: documentViaUser } = trpc.document.get.useQuery({ documentId }, { enabled: !!sessionData?.user });
|
||||
const { data: documentViaUser } = trpc.document.get.useQuery({
|
||||
documentId,
|
||||
});
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(() => !!documentViaUser);
|
||||
|
||||
|
||||
@@ -66,10 +66,6 @@ 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,7 +53,6 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
@@ -69,7 +68,6 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentDateFormat === null ||
|
||||
includeSenderDetails === null ||
|
||||
includeSigningCertificate === null ||
|
||||
allowPublicCompletedDocumentAccess === null ||
|
||||
includeAuditLog === null ||
|
||||
aiFeaturesEnabled === null
|
||||
) {
|
||||
@@ -85,7 +83,6 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
|
||||
@@ -48,7 +48,6 @@ export default function TeamsSettingsPage() {
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
defaultRecipients,
|
||||
@@ -67,7 +66,6 @@ export default function TeamsSettingsPage() {
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
defaultRecipients,
|
||||
aiFeaturesEnabled,
|
||||
|
||||
@@ -150,6 +150,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
let authLevel = match(actionAuthMethod)
|
||||
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
|
||||
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
|
||||
.with('EXTERNAL_TWO_FACTOR_AUTH', () => _(msg`External Two-Factor Re-Authentication`))
|
||||
.with('PASSWORD', () => _(msg`Password Re-Authentication`))
|
||||
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
|
||||
.with('EXPLICIT_NONE', () => _(msg`Email`))
|
||||
|
||||
@@ -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, EyeIcon, Loader2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useRevalidator } from 'react-router';
|
||||
import { CheckCircle2, Clock8, DownloadIcon, Loader2 } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
@@ -31,8 +31,6 @@ 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);
|
||||
|
||||
@@ -105,26 +103,32 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
export default function CompletedSigningPage({ loaderData }: Route.ComponentProps) {
|
||||
const revalidator = useRevalidator();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const { isDocumentAccessValid, recipientEmail, branding } = loaderData;
|
||||
const signingStatusToken = isDocumentAccessValid ? loaderData.recipient.token : '';
|
||||
const initialSigningStatus = isDocumentAccessValid ? loaderData.document.status : DocumentStatus.PENDING;
|
||||
const {
|
||||
isDocumentAccessValid,
|
||||
canSignUp,
|
||||
recipientName,
|
||||
signatures,
|
||||
document,
|
||||
recipient,
|
||||
recipientEmail,
|
||||
returnToHomePath,
|
||||
branding,
|
||||
} = loaderData;
|
||||
|
||||
// Poll signing status every few seconds
|
||||
const { data: signingStatusData } = trpc.envelope.signingStatus.useQuery(
|
||||
{
|
||||
token: signingStatusToken,
|
||||
token: recipient?.token || '',
|
||||
},
|
||||
{
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === 'COMPLETED' || status === 'REJECTED' ? false : 3000;
|
||||
},
|
||||
initialData: match(initialSigningStatus)
|
||||
refetchInterval: 3000,
|
||||
initialData: match(document?.status)
|
||||
.with(DocumentStatus.COMPLETED, () => ({ status: 'COMPLETED' }) as const)
|
||||
.with(DocumentStatus.REJECTED, () => ({ status: 'REJECTED' }) as const)
|
||||
.with(DocumentStatus.PENDING, () => ({ status: 'PENDING' }) as const)
|
||||
@@ -132,56 +136,9 @@ 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 (
|
||||
<>
|
||||
@@ -191,8 +148,6 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
);
|
||||
}
|
||||
|
||||
const { canSignUp, recipientName, signatures, document, recipient, returnToHomePath } = loaderData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
@@ -294,40 +249,6 @@ 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}
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
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 { 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 { redirect, useLoaderData } from 'react-router';
|
||||
|
||||
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 }, loaderData }: Route.MetaArgs) {
|
||||
export function meta({ params: { slug } }: Route.MetaArgs) {
|
||||
if (slug.startsWith('qr_')) {
|
||||
const documentTitle = loaderData?.document?.title ?? 'Shared Document';
|
||||
|
||||
return [...appMetaTags(documentTitle), { name: 'robots', content: 'noindex, nofollow' }];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -62,149 +49,18 @@ export function meta({ params: { slug }, loaderData }: Route.MetaArgs) {
|
||||
];
|
||||
}
|
||||
|
||||
type TQrShareErrorPayload = {
|
||||
code: string;
|
||||
correlationId: string;
|
||||
};
|
||||
|
||||
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 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),
|
||||
});
|
||||
const document = await getDocumentByAccessToken({ token: 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)),
|
||||
},
|
||||
});
|
||||
if (!document) {
|
||||
throw redirect('/');
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
return {
|
||||
document,
|
||||
token: slug,
|
||||
};
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get('User-Agent') ?? '';
|
||||
@@ -235,45 +91,5 @@ export default function SharePage() {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { i18n, type MessageDescriptor } from '@lingui/core';
|
||||
|
||||
export const appMetaTags = (title?: MessageDescriptor | string) => {
|
||||
export const appMetaTags = (title?: MessageDescriptor) => {
|
||||
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: resolvedTitle ? `${resolvedTitle} - Documenso` : 'Documenso',
|
||||
title: title ? `${i18n._(title)} - Documenso` : 'Documenso',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
|
||||
@@ -2,17 +2,12 @@ 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 { DocumentStatus, type Prisma } from '@prisma/client';
|
||||
import { type Context, Hono } from 'hono';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
|
||||
@@ -29,101 +24,6 @@ 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
|
||||
@@ -349,20 +249,46 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
async (c) => {
|
||||
const { token, envelopeItemId } = c.req.valid('param');
|
||||
|
||||
const result = await getEnvelopeItemByToken(c, token, envelopeItemId);
|
||||
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if ('limited' in result) {
|
||||
return result.limited;
|
||||
if (token.startsWith('qr_')) {
|
||||
envelopeWhereQuery = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
qrToken: token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if ('error' in result) {
|
||||
return result.error;
|
||||
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);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemFileRequest({
|
||||
title: result.envelopeItem.title,
|
||||
status: result.envelopeItem.envelope.status,
|
||||
documentData: result.envelopeItem.documentData!,
|
||||
title: envelopeItem.title,
|
||||
status: envelopeItem.envelope.status,
|
||||
documentData: envelopeItem.documentData,
|
||||
version: 'signed',
|
||||
isDownload: false,
|
||||
context: c,
|
||||
@@ -375,23 +301,47 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
async (c) => {
|
||||
const { token, envelopeItemId, version } = c.req.valid('param');
|
||||
|
||||
const result = await getEnvelopeItemByToken(c, token, envelopeItemId);
|
||||
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if ('limited' in result) {
|
||||
return result.limited;
|
||||
if (token.startsWith('qr_')) {
|
||||
envelopeWhereQuery = {
|
||||
id: envelopeItemId,
|
||||
envelope: {
|
||||
qrToken: token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if ('error' in result) {
|
||||
return result.error;
|
||||
const envelopeItem = await prisma.envelopeItem.findUnique({
|
||||
where: envelopeWhereQuery,
|
||||
include: {
|
||||
envelope: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelopeItem) {
|
||||
return c.json({ error: 'Envelope item not found' }, 404);
|
||||
}
|
||||
|
||||
const effectiveVersion = result.isQrToken ? 'signed' : version;
|
||||
if (!envelopeItem.documentData) {
|
||||
return c.json({ error: 'Document data not found' }, 404);
|
||||
}
|
||||
|
||||
return await handleEnvelopeItemFileRequest({
|
||||
title: result.envelopeItem.title,
|
||||
status: result.envelopeItem.envelope.status,
|
||||
documentData: result.envelopeItem.documentData!,
|
||||
version: effectiveVersion,
|
||||
title: envelopeItem.title,
|
||||
status: envelopeItem.envelope.status,
|
||||
documentData: envelopeItem.documentData,
|
||||
version,
|
||||
isDownload: true,
|
||||
context: c,
|
||||
});
|
||||
|
||||
@@ -26,8 +26,21 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
|
||||
key: DocumentAuth.PASSWORD,
|
||||
value: msg`Require password`,
|
||||
},
|
||||
[DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH]: {
|
||||
key: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
value: msg`Require external 2FA`,
|
||||
},
|
||||
[DocumentAuth.EXPLICIT_NONE]: {
|
||||
key: DocumentAuth.EXPLICIT_NONE,
|
||||
value: msg`None (Overrides global settings)`,
|
||||
},
|
||||
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
|
||||
|
||||
export const SIGNING_2FA_VERIFY_REASON_CODES = {
|
||||
TWO_FA_TOKEN_INVALID: 'TWO_FA_TOKEN_INVALID',
|
||||
TWO_FA_TOKEN_EXPIRED: 'TWO_FA_TOKEN_EXPIRED',
|
||||
TWO_FA_TOKEN_REVOKED: 'TWO_FA_TOKEN_REVOKED',
|
||||
TWO_FA_TOKEN_CONSUMED: 'TWO_FA_TOKEN_CONSUMED',
|
||||
TWO_FA_ATTEMPT_LIMIT_REACHED: 'TWO_FA_ATTEMPT_LIMIT_REACHED',
|
||||
TWO_FA_NOT_ISSUED: 'TWO_FA_NOT_ISSUED',
|
||||
} as const;
|
||||
|
||||
@@ -115,11 +115,28 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
// Check ACCESS AUTH 2FA validation during document completion
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
if (derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH)) {
|
||||
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
|
||||
where: {
|
||||
sessionId: token,
|
||||
envelopeId: envelope.id,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
if (!validProof) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'External 2FA verification required before completing document',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
|
||||
if (!accessAuthOptions) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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';
|
||||
|
||||
@@ -11,41 +9,25 @@ export type GetDocumentByAccessTokenOptions = {
|
||||
|
||||
export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Missing QR access token',
|
||||
statusCode: 401,
|
||||
});
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -74,33 +56,17 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'QR token not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.status !== DocumentStatus.COMPLETED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document is not fully completed',
|
||||
statusCode: 409,
|
||||
});
|
||||
if (result.envelopeItems.length === 0) {
|
||||
throw new Error('Completed envelope has no items');
|
||||
}
|
||||
|
||||
if (!isPublicDocumentAccessEnabled(result.team)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Public completed-document access is disabled for this document',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
const firstDocumentData = result.envelopeItems[0].documentData;
|
||||
|
||||
const firstEnvelopeItem = result.envelopeItems[0];
|
||||
|
||||
if (!firstEnvelopeItem?.documentData) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Missing document data for QR token',
|
||||
statusCode: 404,
|
||||
});
|
||||
if (!firstDocumentData) {
|
||||
throw new Error('Missing document data');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -31,6 +31,7 @@ type IsRecipientAuthorizedOptions = {
|
||||
* using the user ID.
|
||||
*/
|
||||
authOptions?: TDocumentAuthMethods;
|
||||
recipientToken?: string;
|
||||
};
|
||||
|
||||
const getUserByEmail = async (email: string) => {
|
||||
@@ -56,6 +57,7 @@ export const isRecipientAuthorized = async ({
|
||||
recipient,
|
||||
userId,
|
||||
authOptions,
|
||||
recipientToken,
|
||||
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
|
||||
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: documentAuthOptions,
|
||||
@@ -163,6 +165,21 @@ export const isRecipientAuthorized = async ({
|
||||
password,
|
||||
});
|
||||
})
|
||||
.with({ type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, async () => {
|
||||
if (!recipientToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
|
||||
where: {
|
||||
sessionId: recipientToken,
|
||||
envelopeId: recipient.envelopeId,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
return !!validProof;
|
||||
})
|
||||
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
|
||||
return true;
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ export type ValidateFieldAuthOptions = {
|
||||
field: Field;
|
||||
userId?: number;
|
||||
authOptions?: TRecipientActionAuth;
|
||||
recipientToken?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ export const validateFieldAuth = async ({
|
||||
field,
|
||||
userId,
|
||||
authOptions,
|
||||
recipientToken,
|
||||
}: ValidateFieldAuthOptions) => {
|
||||
// Override all non-signature fields to not require any auth.
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
@@ -36,6 +38,7 @@ export const validateFieldAuth = async ({
|
||||
recipient,
|
||||
userId,
|
||||
authOptions,
|
||||
recipientToken,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
|
||||
@@ -177,6 +177,7 @@ export const signFieldWithToken = async ({
|
||||
field,
|
||||
userId,
|
||||
authOptions,
|
||||
recipientToken: token,
|
||||
});
|
||||
|
||||
const documentMeta = await prisma.documentMeta.findFirst({
|
||||
|
||||
@@ -101,6 +101,7 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
|
||||
let authLevel = match(actionAuthMethod)
|
||||
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
|
||||
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Re-Authentication`))
|
||||
.with('EXTERNAL_TWO_FACTOR_AUTH', () => i18n._(msg`External Two-Factor Re-Authentication`))
|
||||
.with('PASSWORD', () => i18n._(msg`Password Re-Authentication`))
|
||||
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
|
||||
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
|
||||
|
||||
@@ -97,10 +97,3 @@ export const fileUploadRateLimit = createRateLimit({
|
||||
max: 20,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const qrShareViewRateLimit = createRateLimit({
|
||||
action: 'app.qr-share-view',
|
||||
max: 20,
|
||||
globalMax: 120,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
|
||||
export type GetSigningTwoFactorStatusOptions = {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type SigningTwoFactorStatus = {
|
||||
required: boolean;
|
||||
hasActiveToken: boolean;
|
||||
hasValidProof: boolean;
|
||||
tokenExpiresAt: Date | null;
|
||||
proofExpiresAt: Date | null;
|
||||
attemptsRemaining: number | null;
|
||||
};
|
||||
|
||||
const NOT_REQUIRED_STATUS: SigningTwoFactorStatus = {
|
||||
required: false,
|
||||
hasActiveToken: false,
|
||||
hasValidProof: false,
|
||||
tokenExpiresAt: null,
|
||||
proofExpiresAt: null,
|
||||
attemptsRemaining: null,
|
||||
};
|
||||
|
||||
export const getSigningTwoFactorStatus = async ({
|
||||
recipientId,
|
||||
envelopeId,
|
||||
sessionId,
|
||||
}: GetSigningTwoFactorStatusOptions): Promise<SigningTwoFactorStatus> => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: { id: envelopeId },
|
||||
select: {
|
||||
authOptions: true,
|
||||
recipients: {
|
||||
where: { id: recipientId },
|
||||
select: {
|
||||
authOptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope || envelope.recipients.length === 0) {
|
||||
return NOT_REQUIRED_STATUS;
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const required = derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH);
|
||||
|
||||
if (!required) {
|
||||
return NOT_REQUIRED_STATUS;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const [activeToken, validProof] = await Promise.all([
|
||||
prisma.signingTwoFactorToken.findFirst({
|
||||
where: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
status: 'ACTIVE',
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
expiresAt: true,
|
||||
attempts: true,
|
||||
attemptLimit: true,
|
||||
},
|
||||
}),
|
||||
prisma.signingSessionTwoFactorProof.findFirst({
|
||||
where: {
|
||||
sessionId,
|
||||
recipientId,
|
||||
envelopeId,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
select: {
|
||||
expiresAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
required: true,
|
||||
hasActiveToken: !!activeToken,
|
||||
hasValidProof: !!validProof,
|
||||
tokenExpiresAt: activeToken?.expiresAt ?? null,
|
||||
proofExpiresAt: validProof?.expiresAt ?? null,
|
||||
attemptsRemaining: activeToken
|
||||
? Math.max(0, activeToken.attemptLimit - activeToken.attempts)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { generateSigningTwoFactorToken, generateTokenSalt, hashToken } from './token-utils';
|
||||
|
||||
const TOKEN_TTL_MINUTES = 10;
|
||||
const DEFAULT_ATTEMPT_LIMIT = 5;
|
||||
|
||||
export const SIGNING_2FA_REASON_CODES = {
|
||||
TWO_FA_NOT_REQUIRED: 'TWO_FA_NOT_REQUIRED',
|
||||
TWO_FA_RECIPIENT_INELIGIBLE: 'TWO_FA_RECIPIENT_INELIGIBLE',
|
||||
TWO_FA_ISSUER_FORBIDDEN: 'TWO_FA_ISSUER_FORBIDDEN',
|
||||
} as const;
|
||||
|
||||
export type IssueSigningTwoFactorTokenOptions = {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
apiTokenId: number;
|
||||
};
|
||||
|
||||
export const issueSigningTwoFactorToken = async ({
|
||||
recipientId,
|
||||
envelopeId,
|
||||
apiTokenId,
|
||||
}: IssueSigningTwoFactorTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Document must be in PENDING status`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found for this document',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const requiresExternal2FA = derivedRecipientActionAuth.includes(
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
);
|
||||
|
||||
if (!requiresExternal2FA) {
|
||||
await throwIssuanceDenied({
|
||||
envelopeId,
|
||||
recipient,
|
||||
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_NOT_REQUIRED,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === 'SIGNED') {
|
||||
await throwIssuanceDenied({
|
||||
envelopeId,
|
||||
recipient,
|
||||
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_RECIPIENT_INELIGIBLE,
|
||||
});
|
||||
}
|
||||
|
||||
const plaintextToken = generateSigningTwoFactorToken();
|
||||
const salt = generateTokenSalt();
|
||||
const tokenHash = hashToken(plaintextToken, salt);
|
||||
const expiresAt = new Date(Date.now() + TOKEN_TTL_MINUTES * 60 * 1000);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.signingTwoFactorToken.updateMany({
|
||||
where: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
data: {
|
||||
status: 'REVOKED',
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const newToken = await tx.signingTwoFactorToken.create({
|
||||
data: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
tokenHash,
|
||||
tokenSalt: salt,
|
||||
expiresAt,
|
||||
attemptLimit: DEFAULT_ATTEMPT_LIMIT,
|
||||
issuedByApiTokenId: apiTokenId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
tokenId: newToken.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return newToken;
|
||||
});
|
||||
|
||||
return {
|
||||
token: plaintextToken,
|
||||
tokenId: result.id,
|
||||
expiresAt: result.expiresAt,
|
||||
ttlSeconds: TOKEN_TTL_MINUTES * 60,
|
||||
attemptLimit: result.attemptLimit,
|
||||
issuedAt: result.createdAt,
|
||||
};
|
||||
};
|
||||
|
||||
const throwIssuanceDenied = async ({
|
||||
envelopeId,
|
||||
recipient,
|
||||
reasonCode,
|
||||
}: {
|
||||
envelopeId: string;
|
||||
recipient: { id: number; email: string; name: string | null };
|
||||
reasonCode: string;
|
||||
}) => {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name ?? '',
|
||||
reasonCode,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: reasonCode,
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const TOKEN_LENGTH = 6;
|
||||
const SALT_LENGTH = 32;
|
||||
const HASH_ITERATIONS = 100000;
|
||||
const HASH_KEY_LENGTH = 64;
|
||||
const HASH_DIGEST = 'sha512';
|
||||
|
||||
export const generateSigningTwoFactorToken = (): string => {
|
||||
const bytes = crypto.randomBytes(4);
|
||||
const num = bytes.readUInt32BE(0) % 10 ** TOKEN_LENGTH;
|
||||
|
||||
return num.toString().padStart(TOKEN_LENGTH, '0');
|
||||
};
|
||||
|
||||
export const generateTokenSalt = (): string => {
|
||||
return crypto.randomBytes(SALT_LENGTH).toString('hex');
|
||||
};
|
||||
|
||||
export const hashToken = (token: string, salt: string): string => {
|
||||
return crypto
|
||||
.pbkdf2Sync(token, salt, HASH_ITERATIONS, HASH_KEY_LENGTH, HASH_DIGEST)
|
||||
.toString('hex');
|
||||
};
|
||||
|
||||
export const verifyTokenHash = (token: string, salt: string, expectedHash: string): boolean => {
|
||||
const hash = hashToken(token, salt);
|
||||
|
||||
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expectedHash, 'hex'));
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { SIGNING_2FA_VERIFY_REASON_CODES } from '../../constants/document-auth';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { verifyTokenHash } from './token-utils';
|
||||
|
||||
export { SIGNING_2FA_VERIFY_REASON_CODES };
|
||||
|
||||
const PROOF_TTL_MINUTES = 10;
|
||||
|
||||
export type VerifySigningTwoFactorTokenOptions = {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
token: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export const verifySigningTwoFactorToken = async ({
|
||||
recipientId,
|
||||
envelopeId,
|
||||
token: plaintextToken,
|
||||
sessionId,
|
||||
}: VerifySigningTwoFactorTokenOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelopeId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const activeToken = await prisma.signingTwoFactorToken.findFirst({
|
||||
where: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (!activeToken) {
|
||||
await throwVerificationError({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId: 'none',
|
||||
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED,
|
||||
attemptsUsed: 0,
|
||||
attemptLimit: 0,
|
||||
errorCode: AppErrorCode.INVALID_REQUEST,
|
||||
statusCode: 400,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeToken.expiresAt < new Date()) {
|
||||
await prisma.signingTwoFactorToken.update({
|
||||
where: { id: activeToken.id },
|
||||
data: {
|
||||
status: 'EXPIRED',
|
||||
},
|
||||
});
|
||||
|
||||
await throwVerificationError({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId: activeToken.id,
|
||||
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED,
|
||||
attemptsUsed: activeToken.attempts,
|
||||
attemptLimit: activeToken.attemptLimit,
|
||||
errorCode: AppErrorCode.EXPIRED_CODE,
|
||||
statusCode: 400,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeToken.attempts >= activeToken.attemptLimit) {
|
||||
await prisma.signingTwoFactorToken.update({
|
||||
where: { id: activeToken.id },
|
||||
data: {
|
||||
status: 'REVOKED',
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await throwVerificationError({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId: activeToken.id,
|
||||
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED,
|
||||
attemptsUsed: activeToken.attempts,
|
||||
attemptLimit: activeToken.attemptLimit,
|
||||
errorCode: AppErrorCode.TOO_MANY_REQUESTS,
|
||||
statusCode: 429,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = verifyTokenHash(plaintextToken, activeToken.tokenSalt, activeToken.tokenHash);
|
||||
|
||||
if (!isValid) {
|
||||
const updatedToken = await prisma.signingTwoFactorToken.update({
|
||||
where: { id: activeToken.id },
|
||||
data: {
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await throwVerificationError({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId: activeToken.id,
|
||||
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_INVALID,
|
||||
attemptsUsed: updatedToken.attempts,
|
||||
attemptLimit: updatedToken.attemptLimit,
|
||||
errorCode: AppErrorCode.INVALID_REQUEST,
|
||||
statusCode: 400,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const proofExpiresAt = new Date(Date.now() + PROOF_TTL_MINUTES * 60 * 1000);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.signingTwoFactorToken.update({
|
||||
where: { id: activeToken.id },
|
||||
data: {
|
||||
status: 'CONSUMED',
|
||||
consumedAt: new Date(),
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const proof = await tx.signingSessionTwoFactorProof.upsert({
|
||||
where: {
|
||||
sessionId_recipientId_envelopeId: {
|
||||
sessionId,
|
||||
recipientId,
|
||||
envelopeId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
sessionId,
|
||||
recipientId,
|
||||
envelopeId,
|
||||
expiresAt: proofExpiresAt,
|
||||
},
|
||||
update: {
|
||||
verifiedAt: new Date(),
|
||||
expiresAt: proofExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
tokenId: activeToken.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
tokenId: activeToken.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return proof;
|
||||
});
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
proofId: result.id,
|
||||
expiresAt: result.expiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
type ThrowVerificationErrorOptions = {
|
||||
envelopeId: string;
|
||||
recipient: { id: number; email: string; name: string };
|
||||
tokenId: string;
|
||||
reasonCode: string;
|
||||
attemptsUsed: number;
|
||||
attemptLimit: number;
|
||||
errorCode: AppErrorCode;
|
||||
statusCode: number;
|
||||
};
|
||||
|
||||
const throwVerificationError = async ({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId,
|
||||
reasonCode,
|
||||
attemptsUsed,
|
||||
attemptLimit,
|
||||
errorCode,
|
||||
statusCode,
|
||||
}: ThrowVerificationErrorOptions): Promise<never> => {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
tokenId,
|
||||
reasonCode,
|
||||
attemptsUsed,
|
||||
attemptLimit,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
throw new AppError(errorCode, {
|
||||
message: reasonCode,
|
||||
statusCode,
|
||||
});
|
||||
};
|
||||
@@ -54,6 +54,14 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
|
||||
|
||||
// External signing 2FA events.
|
||||
'EXTERNAL_2FA_TOKEN_ISSUED',
|
||||
'EXTERNAL_2FA_TOKEN_ISSUE_DENIED',
|
||||
'EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED',
|
||||
'EXTERNAL_2FA_TOKEN_VERIFY_FAILED',
|
||||
'EXTERNAL_2FA_TOKEN_CONSUMED',
|
||||
'EXTERNAL_2FA_TOKEN_REVOKED',
|
||||
]);
|
||||
|
||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
@@ -710,6 +718,59 @@ export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.objec
|
||||
}),
|
||||
});
|
||||
|
||||
const ZExternal2FARecipientDataSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
recipientEmail: z.string(),
|
||||
recipientName: z.string(),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenIssuedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
reasonCode: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
reasonCode: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
reasonCode: z.string(),
|
||||
attemptsUsed: z.number(),
|
||||
attemptLimit: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenConsumedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenRevokedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
reasonCode: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient's signing window expired.
|
||||
*/
|
||||
@@ -769,6 +830,12 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenIssuedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenConsumedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenRevokedSchema,
|
||||
ZDocumentAuditLogEventRecipientExpiredSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,14 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
|
||||
/**
|
||||
* All the available types of document authentication options for both access and action.
|
||||
*/
|
||||
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'TWO_FACTOR_AUTH', 'PASSWORD', 'EXPLICIT_NONE']);
|
||||
export const ZDocumentAuthTypesSchema = z.enum([
|
||||
'ACCOUNT',
|
||||
'PASSKEY',
|
||||
'TWO_FACTOR_AUTH',
|
||||
'EXTERNAL_TWO_FACTOR_AUTH',
|
||||
'PASSWORD',
|
||||
'EXPLICIT_NONE',
|
||||
]);
|
||||
|
||||
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
||||
|
||||
@@ -34,6 +41,10 @@ const ZDocumentAuth2FASchema = z.object({
|
||||
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
|
||||
});
|
||||
|
||||
const ZDocumentAuthExternal2FASchema = z.object({
|
||||
type: z.literal(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH),
|
||||
});
|
||||
|
||||
/**
|
||||
* All the document auth methods for both accessing and actioning.
|
||||
*/
|
||||
@@ -42,6 +53,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExternal2FASchema,
|
||||
ZDocumentAuthPasswordSchema,
|
||||
]);
|
||||
|
||||
@@ -67,10 +79,17 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExternal2FASchema,
|
||||
ZDocumentAuthPasswordSchema,
|
||||
]);
|
||||
export const ZDocumentActionAuthTypesSchema = z
|
||||
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD])
|
||||
.enum([
|
||||
DocumentAuth.ACCOUNT,
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
DocumentAuth.PASSWORD,
|
||||
])
|
||||
.describe(
|
||||
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
|
||||
);
|
||||
@@ -97,6 +116,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExternal2FASchema,
|
||||
ZDocumentAuthPasswordSchema,
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
]);
|
||||
@@ -105,6 +125,7 @@ export const ZRecipientActionAuthTypesSchema = z
|
||||
DocumentAuth.ACCOUNT,
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
DocumentAuth.PASSWORD,
|
||||
DocumentAuth.EXPLICIT_NONE,
|
||||
])
|
||||
|
||||
@@ -34,6 +34,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
|
||||
allowLegacyEnvelopes: z.boolean().optional(),
|
||||
|
||||
externalSigning2fa: z.boolean().optional(),
|
||||
|
||||
signingReminders: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -102,6 +104,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'allowLegacyEnvelopes',
|
||||
label: 'Allow Legacy Envelopes',
|
||||
},
|
||||
externalSigning2fa: {
|
||||
key: 'externalSigning2fa',
|
||||
label: 'External signing 2FA',
|
||||
isEnterprise: true,
|
||||
},
|
||||
signingReminders: {
|
||||
key: 'signingReminders',
|
||||
label: 'Signing reminders',
|
||||
|
||||
@@ -31,8 +31,4 @@ 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 };
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -597,6 +597,48 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
user: message,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA token issued for recipient ${data.recipientEmail}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA token issuance denied for recipient ${data.recipientEmail}: ${data.reasonCode}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA verification succeeded for recipient ${data.recipientEmail}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA verification failed for recipient ${data.recipientEmail}: ${data.reasonCode} (attempt ${data.attemptsUsed}/${data.attemptLimit})`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA token consumed for recipient ${data.recipientEmail}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA token revoked for recipient ${data.recipientEmail}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
let selectedDescription = description.anonymous;
|
||||
|
||||
@@ -114,7 +114,6 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
|
||||
|
||||
includeSenderDetails: true,
|
||||
includeSigningCertificate: true,
|
||||
allowPublicCompletedDocumentAccess: true,
|
||||
includeAuditLog: false,
|
||||
|
||||
typedSignatureEnabled: true,
|
||||
|
||||
@@ -181,7 +181,6 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
|
||||
includeSenderDetails: null,
|
||||
includeSigningCertificate: null,
|
||||
allowPublicCompletedDocumentAccess: null,
|
||||
includeAuditLog: null,
|
||||
|
||||
typedSignatureEnabled: null,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SigningTwoFactorTokenStatus" AS ENUM ('ACTIVE', 'CONSUMED', 'REVOKED', 'EXPIRED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SigningTwoFactorToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"recipientId" INTEGER NOT NULL,
|
||||
"envelopeId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenSalt" TEXT NOT NULL,
|
||||
"status" "SigningTwoFactorTokenStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"attemptLimit" INTEGER NOT NULL DEFAULT 5,
|
||||
"issuedByApiTokenId" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SigningTwoFactorToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SigningSessionTwoFactorProof" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"recipientId" INTEGER NOT NULL,
|
||||
"envelopeId" TEXT NOT NULL,
|
||||
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SigningSessionTwoFactorProof_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SigningTwoFactorToken_recipientId_envelopeId_status_idx" ON "SigningTwoFactorToken"("recipientId", "envelopeId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SigningTwoFactorToken_envelopeId_idx" ON "SigningTwoFactorToken"("envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SigningSessionTwoFactorProof_recipientId_envelopeId_idx" ON "SigningSessionTwoFactorProof"("recipientId", "envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SigningSessionTwoFactorProof_expiresAt_idx" ON "SigningSessionTwoFactorProof"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SigningSessionTwoFactorProof_sessionId_recipientId_envelope_key" ON "SigningSessionTwoFactorProof"("sessionId", "recipientId", "envelopeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
ALTER TABLE "OrganisationGlobalSettings"
|
||||
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "TeamGlobalSettings"
|
||||
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN;
|
||||
@@ -1 +0,0 @@
|
||||
CREATE INDEX "Envelope_qrToken_idx" ON "Envelope"("qrToken");
|
||||
@@ -437,13 +437,15 @@ model Envelope {
|
||||
|
||||
envelopeAttachments EnvelopeAttachment[]
|
||||
|
||||
signingTwoFactorTokens SigningTwoFactorToken[]
|
||||
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
|
||||
|
||||
@@index([type])
|
||||
@@index([status])
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@index([folderId])
|
||||
@@index([createdAt])
|
||||
@@index([qrToken])
|
||||
}
|
||||
|
||||
model EnvelopeItem {
|
||||
@@ -605,6 +607,9 @@ model Recipient {
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
signingTwoFactorTokens SigningTwoFactorToken[]
|
||||
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
|
||||
|
||||
@@index([token])
|
||||
@@index([email])
|
||||
@@index([envelopeId])
|
||||
@@ -841,7 +846,6 @@ 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")
|
||||
@@ -888,7 +892,6 @@ model TeamGlobalSettings {
|
||||
|
||||
includeSenderDetails Boolean?
|
||||
includeSigningCertificate Boolean?
|
||||
allowPublicCompletedDocumentAccess Boolean?
|
||||
includeAuditLog Boolean?
|
||||
|
||||
typedSignatureEnabled Boolean?
|
||||
@@ -1118,6 +1121,58 @@ model Counter {
|
||||
value Int
|
||||
}
|
||||
|
||||
enum SigningTwoFactorTokenStatus {
|
||||
ACTIVE
|
||||
CONSUMED
|
||||
REVOKED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
model SigningTwoFactorToken {
|
||||
id String @id @default(cuid())
|
||||
|
||||
recipientId Int
|
||||
envelopeId String
|
||||
|
||||
tokenHash String
|
||||
tokenSalt String
|
||||
|
||||
status SigningTwoFactorTokenStatus @default(ACTIVE)
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
attempts Int @default(0)
|
||||
attemptLimit Int @default(5)
|
||||
|
||||
issuedByApiTokenId Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([recipientId, envelopeId, status])
|
||||
@@index([envelopeId])
|
||||
}
|
||||
|
||||
model SigningSessionTwoFactorProof {
|
||||
id String @id @default(cuid())
|
||||
|
||||
sessionId String
|
||||
recipientId Int
|
||||
envelopeId String
|
||||
|
||||
verifiedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, recipientId, envelopeId])
|
||||
@@index([recipientId, envelopeId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model RateLimit {
|
||||
key String
|
||||
action String
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { maybeAuthenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
@@ -57,13 +56,15 @@ 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,
|
||||
...(isQrToken ? { qrToken: token, status: DocumentStatus.COMPLETED } : { recipients: { some: { token } } }),
|
||||
type: EnvelopeType.DOCUMENT, // You cannot get template envelope items by token.
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
@@ -71,20 +72,6 @@ const handleGetEnvelopeItemsByToken = async ({ envelopeId, token }: { envelopeId
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
include: {
|
||||
teamGlobalSettings: {
|
||||
select: { allowPublicCompletedDocumentAccess: true },
|
||||
},
|
||||
organisation: {
|
||||
include: {
|
||||
organisationGlobalSettings: {
|
||||
select: { allowPublicCompletedDocumentAccess: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,12 +81,6 @@ 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,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,9 @@ import { saveAsTemplateRoute } from './save-as-template';
|
||||
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
|
||||
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
|
||||
import { signEnvelopeFieldRoute } from './sign-envelope-field';
|
||||
import { getSigningTwoFactorStatusRoute } from './signing-2fa/get-signing-two-factor-status';
|
||||
import { issueSigningTwoFactorTokenRoute } from './signing-2fa/issue-signing-two-factor-token';
|
||||
import { verifySigningTwoFactorTokenRoute } from './signing-2fa/verify-signing-two-factor-token';
|
||||
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
|
||||
import { updateEnvelopeRoute } from './update-envelope';
|
||||
import { updateEnvelopeItemsRoute } from './update-envelope-items';
|
||||
@@ -97,5 +100,10 @@ export const envelopeRouter = router({
|
||||
saveAsTemplate: saveAsTemplateRoute,
|
||||
distribute: distributeEnvelopeRoute,
|
||||
redistribute: redistributeEnvelopeRoute,
|
||||
signing2fa: {
|
||||
issue: issueSigningTwoFactorTokenRoute,
|
||||
verify: verifySigningTwoFactorTokenRoute,
|
||||
getStatus: getSigningTwoFactorStatusRoute,
|
||||
},
|
||||
signingStatus: signingStatusEnvelopeRoute,
|
||||
});
|
||||
|
||||
@@ -176,6 +176,7 @@ export const signEnvelopeFieldRoute = procedure
|
||||
field,
|
||||
userId: user?.id,
|
||||
authOptions,
|
||||
recipientToken: token,
|
||||
});
|
||||
|
||||
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getSigningTwoFactorStatus } from '@documenso/lib/server-only/signing-2fa/get-signing-two-factor-status';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import {
|
||||
ZGetSigningTwoFactorStatusRequestSchema,
|
||||
ZGetSigningTwoFactorStatusResponseSchema,
|
||||
} from './get-signing-two-factor-status.types';
|
||||
|
||||
export const getSigningTwoFactorStatusRoute = procedure
|
||||
.input(ZGetSigningTwoFactorStatusRequestSchema)
|
||||
.output(ZGetSigningTwoFactorStatusResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { token } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
token: '***',
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return await getSigningTwoFactorStatus({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
sessionId: token,
|
||||
});
|
||||
});
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetSigningTwoFactorStatusRequestSchema = z.object({
|
||||
token: z.string().describe('The recipient signing token from the signing URL.'),
|
||||
});
|
||||
|
||||
export const ZGetSigningTwoFactorStatusResponseSchema = z.object({
|
||||
required: z.boolean().describe('Whether external 2FA is required for this recipient.'),
|
||||
hasActiveToken: z.boolean().describe('Whether an active (unexpired) token exists.'),
|
||||
hasValidProof: z.boolean().describe('Whether a valid session proof exists.'),
|
||||
tokenExpiresAt: z.date().nullable().describe('When the active token expires, if any.'),
|
||||
proofExpiresAt: z.date().nullable().describe('When the session proof expires, if any.'),
|
||||
attemptsRemaining: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe('Remaining verification attempts for the active token.'),
|
||||
});
|
||||
|
||||
export type TGetSigningTwoFactorStatusRequest = z.infer<
|
||||
typeof ZGetSigningTwoFactorStatusRequestSchema
|
||||
>;
|
||||
export type TGetSigningTwoFactorStatusResponse = z.infer<
|
||||
typeof ZGetSigningTwoFactorStatusResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||
import { issueSigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/issue-signing-two-factor-token';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZIssueSigningTwoFactorTokenRequestSchema,
|
||||
ZIssueSigningTwoFactorTokenResponseSchema,
|
||||
issueSigningTwoFactorTokenMeta,
|
||||
} from './issue-signing-two-factor-token.types';
|
||||
|
||||
export const issueSigningTwoFactorTokenRoute = authenticatedProcedure
|
||||
.meta(issueSigningTwoFactorTokenMeta)
|
||||
.input(ZIssueSigningTwoFactorTokenRequestSchema)
|
||||
.output(ZIssueSigningTwoFactorTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { envelopeId, recipientId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
const authorizationHeader = ctx.req.headers.get('authorization');
|
||||
|
||||
if (!authorizationHeader) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token required to issue signing 2FA tokens',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token required to issue signing 2FA tokens',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
const result = await issueSigningTwoFactorToken({
|
||||
recipientId,
|
||||
envelopeId,
|
||||
apiTokenId: apiToken.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const issueSigningTwoFactorTokenMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/signing-2fa/issue',
|
||||
summary: 'Issue a signing 2FA token',
|
||||
description:
|
||||
'Issue a one-time signing two-factor authentication token for a recipient. The caller is responsible for delivering the token to the signer through their own channel (e.g., SMS).',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZIssueSigningTwoFactorTokenRequestSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope.'),
|
||||
recipientId: z.number().describe('The ID of the recipient to issue the token for.'),
|
||||
});
|
||||
|
||||
export const ZIssueSigningTwoFactorTokenResponseSchema = z.object({
|
||||
token: z.string().describe('The plaintext one-time token. Visible exactly once.'),
|
||||
tokenId: z.string().describe('The ID of the created token record.'),
|
||||
expiresAt: z.date().describe('When the token expires.'),
|
||||
ttlSeconds: z.number().describe('Token time-to-live in seconds.'),
|
||||
attemptLimit: z.number().describe('Maximum verification attempts allowed.'),
|
||||
issuedAt: z.date().describe('When the token was issued.'),
|
||||
});
|
||||
|
||||
export type TIssueSigningTwoFactorTokenRequest = z.infer<
|
||||
typeof ZIssueSigningTwoFactorTokenRequestSchema
|
||||
>;
|
||||
export type TIssueSigningTwoFactorTokenResponse = z.infer<
|
||||
typeof ZIssueSigningTwoFactorTokenResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifySigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/verify-signing-two-factor-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import {
|
||||
ZVerifySigningTwoFactorTokenRequestSchema,
|
||||
ZVerifySigningTwoFactorTokenResponseSchema,
|
||||
} from './verify-signing-two-factor-token.types';
|
||||
|
||||
export const verifySigningTwoFactorTokenRoute = procedure
|
||||
.input(ZVerifySigningTwoFactorTokenRequestSchema)
|
||||
.output(ZVerifySigningTwoFactorTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, code } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
token: '***',
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await verifySigningTwoFactorToken({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
token: code,
|
||||
sessionId: token,
|
||||
});
|
||||
|
||||
return {
|
||||
verified: result!.verified,
|
||||
expiresAt: result!.expiresAt,
|
||||
};
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZVerifySigningTwoFactorTokenRequestSchema = z.object({
|
||||
token: z.string().describe('The recipient signing token from the signing URL.'),
|
||||
code: z
|
||||
.string()
|
||||
.min(6)
|
||||
.max(6)
|
||||
.regex(/^\d{6}$/)
|
||||
.describe('The 6-digit one-time code to verify.'),
|
||||
});
|
||||
|
||||
export const ZVerifySigningTwoFactorTokenResponseSchema = z.object({
|
||||
verified: z.boolean().describe('Whether the code was successfully verified.'),
|
||||
expiresAt: z.date().describe('When the session proof expires.'),
|
||||
});
|
||||
|
||||
export type TVerifySigningTwoFactorTokenRequest = z.infer<
|
||||
typeof ZVerifySigningTwoFactorTokenRequestSchema
|
||||
>;
|
||||
export type TVerifySigningTwoFactorTokenResponse = z.infer<
|
||||
typeof ZVerifySigningTwoFactorTokenResponseSchema
|
||||
>;
|
||||
@@ -33,7 +33,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
@@ -164,7 +163,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
|
||||
@@ -21,7 +21,6 @@ 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,7 +32,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
@@ -167,7 +166,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
allowPublicCompletedDocumentAccess,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
|
||||
@@ -25,7 +25,6 @@ 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(),
|
||||
|
||||
Reference in New Issue
Block a user