mirror of
https://github.com/documenso/documenso.git
synced 2026-07-11 13:35:20 +10:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 400b6a24f1 | |||
| 1b1e3d197b | |||
| a276e18e1f | |||
| 50f272be87 | |||
| a55e6d9484 | |||
| d35d13db23 |
@@ -1,289 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -76,6 +76,8 @@ The Enterprise Edition is required when you:
|
|||||||
4. Restart your Documenso instance
|
4. Restart your Documenso instance
|
||||||
5. Verify the license is active in the **Admin Panel** under the **Stats** section
|
5. Verify the license is active in the **Admin Panel** under the **Stats** section
|
||||||
|
|
||||||
|
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
|
||||||
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordions>
|
</Accordions>
|
||||||
|
|
||||||
@@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options.
|
|||||||
1. Sign the Enterprise license agreement
|
1. Sign the Enterprise license agreement
|
||||||
2. Receive license key and access credentials
|
2. Receive license key and access credentials
|
||||||
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
|
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
|
||||||
4. Configure Enterprise features with support assistance
|
4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
|
||||||
|
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
@@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options.
|
|||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
|
||||||
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
|
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
|
||||||
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
|
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
|
||||||
- [Support](/docs/policies/support) - Support channels and response times
|
- [Support](/docs/policies/support) - Support channels and response times
|
||||||
|
|||||||
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
|
|||||||
|
|
||||||
## Enterprise Features
|
## Enterprise Features
|
||||||
|
|
||||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
|
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| ------------------------------------ | ------------------------------------------------ |
|
| ------------------------------------ | ------------------------------------------------ |
|
||||||
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
|
| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it |
|
||||||
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
|
| `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing |
|
||||||
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
|
| `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret |
|
||||||
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
|
| `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification |
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
title: Apply Your License Key
|
||||||
|
description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||||
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance.
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
The license key applies to your **whole instance**, not an individual user account. There's one
|
||||||
|
key per deployment.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an
|
||||||
|
Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com).
|
||||||
|
See [Enterprise Edition](/docs/policies/enterprise-edition) for details.
|
||||||
|
- A running self-hosted Documenso instance that you're able to restart
|
||||||
|
|
||||||
|
## Step 1: Set the environment variable
|
||||||
|
|
||||||
|
Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key.
|
||||||
|
|
||||||
|
<Tabs items={['Docker Compose', 'docker run', '.env']}>
|
||||||
|
<Tab value="Docker Compose">
|
||||||
|
|
||||||
|
Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
Then apply it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
</Tab>
|
||||||
|
<Tab value="docker run">
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name documenso \
|
||||||
|
-e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \
|
||||||
|
documenso/documenso:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
</Tab>
|
||||||
|
<Tab value=".env">
|
||||||
|
|
||||||
|
If you're running Documenso directly (not in a container), add the variable to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## Step 2: Restart the instance
|
||||||
|
|
||||||
|
The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker Compose
|
||||||
|
docker compose restart documenso
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker restart documenso
|
||||||
|
```
|
||||||
|
|
||||||
|
On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out.
|
||||||
|
|
||||||
|
## What the license enables
|
||||||
|
|
||||||
|
A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature:
|
||||||
|
|
||||||
|
- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup.
|
||||||
|
- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
<Accordions type="multiple">
|
||||||
|
<Accordion title="Enterprise features are still unavailable after applying the key">
|
||||||
|
- Confirm the key is present in the environment the running process actually reads — `docker
|
||||||
|
exec` into the container and check `env | grep LICENSE` if unsure.
|
||||||
|
- Confirm the instance was fully restarted after the variable was set, not just reloaded.
|
||||||
|
- Re-copy the key to rule out truncation or accidental whitespace.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="A specific feature still isn't working">
|
||||||
|
Instance-wide features (like CSC signing) also need their own configuration — an active license
|
||||||
|
alone isn't enough. Check that feature's guide to confirm the required settings are in place.
|
||||||
|
Per-organisation features additionally need to be provisioned for the organisation that's using
|
||||||
|
them.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference
|
||||||
|
- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license
|
||||||
|
- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"title": "Configuration",
|
"title": "Configuration",
|
||||||
"pages": [
|
"pages": [
|
||||||
"environment",
|
"environment",
|
||||||
|
"license",
|
||||||
"database",
|
"database",
|
||||||
"email",
|
"email",
|
||||||
"storage",
|
"storage",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
|
|||||||
|
|
||||||
### Enterprise Edition license
|
### Enterprise Edition license
|
||||||
|
|
||||||
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
|
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
|
||||||
|
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
|
|||||||
|
|
||||||
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
|
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
|
||||||
|
|
||||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
|
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +24,7 @@ import { useParams } from 'react-router';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const ZCreateFolderFormSchema = z.object({
|
const ZCreateFolderFormSchema = z.object({
|
||||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
name: ZNameSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||||
@@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
|
|||||||
toast({
|
toast({
|
||||||
description: t`Folder created successfully`,
|
description: t`Folder created successfully`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to create folder`,
|
title: t`Failed to create folder`,
|
||||||
description: t`An unknown error occurred while creating the folder.`,
|
description: t`An unknown error occurred while creating the folder.`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -23,8 +24,6 @@ import { useEffect } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type FolderUpdateDialogProps = {
|
export type FolderUpdateDialogProps = {
|
||||||
folder: TFolderWithSubfolders | null;
|
folder: TFolderWithSubfolders | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = {
|
|||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
export const ZUpdateFolderFormSchema = z.object({
|
export const ZUpdateFolderFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: ZNameSchema,
|
||||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
|||||||
|
|
||||||
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
|
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -25,14 +26,13 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export type PasskeyCreateDialogProps = {
|
export type PasskeyCreateDialogProps = {
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const ZCreatePasskeyFormSchema = z.object({
|
const ZCreatePasskeyFormSchema = z.object({
|
||||||
passkeyName: z.string().min(3),
|
passkeyName: ZNameSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,16 +20,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
import { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
export type TeamEmailUpdateDialogProps = {
|
export type TeamEmailUpdateDialogProps = {
|
||||||
teamEmail: TeamEmail;
|
teamEmail: TeamEmail;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
const ZUpdateTeamEmailFormSchema = z.object({
|
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
data: true,
|
||||||
});
|
}).shape.data;
|
||||||
|
|
||||||
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: teamEmail.name,
|
name: teamEmail.name,
|
||||||
},
|
},
|
||||||
|
mode: 'onSubmit',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
|
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import {
|
||||||
|
BRANDING_LOGO_ALLOWED_TYPES,
|
||||||
|
BRANDING_LOGO_MAX_SIZE_BYTES,
|
||||||
|
BRANDING_LOGO_MAX_SIZE_MB,
|
||||||
|
} from '@documenso/lib/constants/branding';
|
||||||
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -23,15 +28,15 @@ import { useCspNonce } from '~/utils/nonce';
|
|||||||
|
|
||||||
import { FormStickySaveBar } from './form-sticky-save-bar';
|
import { FormStickySaveBar } from './form-sticky-save-bar';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
||||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
|
||||||
|
|
||||||
const ZBrandingPreferencesFormSchema = z.object({
|
const ZBrandingPreferencesFormSchema = z.object({
|
||||||
brandingEnabled: z.boolean().nullable(),
|
brandingEnabled: z.boolean().nullable(),
|
||||||
brandingLogo: z
|
brandingLogo: z
|
||||||
.instanceof(File)
|
.instanceof(File)
|
||||||
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
|
.refine(
|
||||||
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
|
(file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
|
||||||
|
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
|
||||||
|
)
|
||||||
|
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
|
||||||
.nullish(),
|
.nullish(),
|
||||||
brandingUrl: z.string().url().optional().or(z.literal('')),
|
brandingUrl: z.string().url().optional().or(z.literal('')),
|
||||||
brandingCompanyDetails: z.string().max(500).optional(),
|
brandingCompanyDetails: z.string().max(500).optional(),
|
||||||
@@ -245,7 +250,7 @@ export function BrandingPreferencesForm({
|
|||||||
<FormControl className="relative">
|
<FormControl className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
accept={ACCEPTED_FILE_TYPES.join(',')}
|
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
|
||||||
disabled={!isBrandingEnabled}
|
disabled={!isBrandingEnabled}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const ZEmailTransportFormSchema = z.object({
|
const ZEmailTransportFormSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: ZNameSchema,
|
||||||
fromName: z.string().min(1),
|
fromName: ZNameSchema,
|
||||||
fromAddress: z.string().email(),
|
fromAddress: z.string().email(),
|
||||||
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
|
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
|
||||||
host: z.string().optional(),
|
host: z.string().optional(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
@@ -96,7 +96,7 @@ export const SignUpForm = ({
|
|||||||
password: '',
|
password: '',
|
||||||
signature: '',
|
signature: '',
|
||||||
},
|
},
|
||||||
mode: 'onBlur',
|
mode: 'onChange',
|
||||||
resolver: zodResolver(ZSignUpFormSchema),
|
resolver: zodResolver(ZSignUpFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
|
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||||
|
|
||||||
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
|
|||||||
type AdminGlobalSettingsSectionProps = {
|
type AdminGlobalSettingsSectionProps = {
|
||||||
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
|
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
|
||||||
isTeam?: boolean;
|
isTeam?: boolean;
|
||||||
|
/** When viewing a team, the parent organisation settings the team inherits from. */
|
||||||
|
inheritedSettings?: OrganisationGlobalSettings | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
|
export const AdminGlobalSettingsSection = ({
|
||||||
|
settings,
|
||||||
|
isTeam = false,
|
||||||
|
inheritedSettings,
|
||||||
|
}: AdminGlobalSettingsSectionProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
|
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textValue = (value: string | null | undefined) => {
|
const notSet = <Trans>Not set</Trans>;
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return notSetLabel;
|
const inheritedValue = (value: ReactNode) => {
|
||||||
|
if (!isTeam || value === null) {
|
||||||
|
return notSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<Trans>Inherited</Trans>:
|
||||||
|
</span>
|
||||||
|
<span>{value}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const brandingTextValue = (value: string | null | undefined) => {
|
const textValue = (value: string | null | undefined, inherited?: string | null) => {
|
||||||
if (value === null || value === undefined || value.trim() === '') {
|
if (value && value.trim() !== '') {
|
||||||
return notSetLabel;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
if (inherited && inherited.trim() !== '') {
|
||||||
|
return inheritedValue(inherited);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notSet;
|
||||||
};
|
};
|
||||||
|
|
||||||
const booleanValue = (value: boolean | null | undefined) => {
|
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return notSetLabel;
|
const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
return booleanLabel(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
|
return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityLabel = (value: string | null | undefined) => {
|
||||||
|
return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityValue = (value: string | null | undefined, inherited?: string | null) => {
|
||||||
|
const label = visibilityLabel(value);
|
||||||
|
|
||||||
|
if (label !== null) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inheritedValue(visibilityLabel(inherited));
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
|
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
|
||||||
@@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
|
|||||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<DetailsCard label={<Trans>Document visibility</Trans>}>
|
<DetailsCard label={<Trans>Document visibility</Trans>}>
|
||||||
<DetailsValue>
|
<DetailsValue>
|
||||||
{settings.documentVisibility != null
|
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
|
||||||
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
|
|
||||||
: notSetLabel}
|
|
||||||
</DetailsValue>
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Document language</Trans>}>
|
<DetailsCard label={<Trans>Document language</Trans>}>
|
||||||
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
|
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Document timezone</Trans>}>
|
<DetailsCard label={<Trans>Document timezone</Trans>}>
|
||||||
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
|
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Date format</Trans>}>
|
<DetailsCard label={<Trans>Date format</Trans>}>
|
||||||
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
|
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Include sender details</Trans>}>
|
<DetailsCard label={<Trans>Include sender details</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
|
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Include audit log</Trans>}>
|
<DetailsCard label={<Trans>Include audit log</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
|
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
|
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Upload signature</Trans>}>
|
<DetailsCard label={<Trans>Upload signature</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Draw signature</Trans>}>
|
<DetailsCard label={<Trans>Draw signature</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
|
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding logo</Trans>}>
|
<DetailsCard label={<Trans>Branding logo</Trans>}>
|
||||||
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
|
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding URL</Trans>}>
|
<DetailsCard label={<Trans>Branding URL</Trans>}>
|
||||||
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
|
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding company details</Trans>}>
|
<DetailsCard label={<Trans>Branding company details</Trans>}>
|
||||||
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
|
<DetailsValue>
|
||||||
|
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
|
||||||
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Email reply-to</Trans>}>
|
<DetailsCard label={<Trans>Email reply-to</Trans>}>
|
||||||
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
|
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
{isTeam && parsedEmailSettings.success && (
|
{isTeam && parsedEmailSettings.success && (
|
||||||
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DetailsCard label={<Trans>AI features</Trans>}>
|
<DetailsCard label={<Trans>AI features</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
|
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
@@ -19,7 +20,6 @@ import { useRef } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
|
||||||
|
|
||||||
export type ClaimAccountProps = {
|
export type ClaimAccountProps = {
|
||||||
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
|
|||||||
|
|
||||||
export const ZClaimAccountFormSchema = z
|
export const ZClaimAccountFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
|
name: ZNameSchema,
|
||||||
email: zEmail().min(1),
|
email: zEmail().min(1),
|
||||||
password: ZPasswordSchema,
|
password: ZPasswordSchema,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
@@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
|
|||||||
|
|
||||||
import { RateLimitArrayInput } from './rate-limit-array-input';
|
import { RateLimitArrayInput } from './rate-limit-array-input';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rate-limit editor renders its own per-row inline errors, but a submit
|
||||||
|
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
|
||||||
|
* window). Rendering the field's message here guarantees the form never fails
|
||||||
|
* silently when those errors are not tied to a row the editor is showing.
|
||||||
|
*/
|
||||||
|
|
||||||
type ClaimLimitFieldsProps<T extends FieldValues> = {
|
type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||||
control: Control<T>;
|
control: Control<T>;
|
||||||
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
||||||
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LimitGroup = {
|
||||||
|
title: ReactNode;
|
||||||
|
quotaKey: string;
|
||||||
|
rateLimitKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const ClaimLimitFields = <T extends FieldValues>({
|
export const ClaimLimitFields = <T extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
prefix = '',
|
prefix = '',
|
||||||
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
||||||
|
|
||||||
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
|
const limitGroups: LimitGroup[] = [
|
||||||
|
{
|
||||||
|
title: <Trans>Documents</Trans>,
|
||||||
|
quotaKey: 'documentQuota',
|
||||||
|
rateLimitKey: 'documentRateLimits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <Trans>Emails</Trans>,
|
||||||
|
quotaKey: 'emailQuota',
|
||||||
|
rateLimitKey: 'emailRateLimits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <Trans>API</Trans>,
|
||||||
|
quotaKey: 'apiQuota',
|
||||||
|
rateLimitKey: 'apiRateLimits',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderQuotaField = (group: LimitGroup) => (
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
name={name(key)}
|
name={name(group.quotaKey)}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{label}</FormLabel>
|
<FormLabel className="text-muted-foreground text-xs">
|
||||||
|
<Trans>Monthly quota</Trans>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -47,20 +73,18 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
|||||||
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{description}</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRateLimitField = (key: string, label: ReactNode) => (
|
const renderRateLimitField = (group: LimitGroup) => (
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
name={name(key)}
|
name={name(group.rateLimitKey)}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{label}</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<div className="space-y-3">
|
||||||
<FormLabel>
|
<div>
|
||||||
<Trans>Limits</Trans>
|
<h3 className="font-semibold text-base">
|
||||||
</FormLabel>
|
<Trans>Limits</Trans>
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{renderQuotaField(
|
<div className="overflow-hidden rounded-lg border">
|
||||||
'documentQuota',
|
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
|
||||||
<Trans>Monthly document quota</Trans>,
|
{limitGroups.map((group) => (
|
||||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
<div key={group.quotaKey} className="space-y-4 p-4">
|
||||||
)}
|
<h4 className="font-semibold text-sm">{group.title}</h4>
|
||||||
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
|
|
||||||
|
|
||||||
{renderQuotaField(
|
{renderQuotaField(group)}
|
||||||
'emailQuota',
|
{renderRateLimitField(group)}
|
||||||
<Trans>Monthly email quota</Trans>,
|
</div>
|
||||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
))}
|
||||||
)}
|
</div>
|
||||||
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
|
</div>
|
||||||
|
|
||||||
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
|
|
||||||
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-13
@@ -13,7 +13,6 @@ import { match, P } from 'ts-pattern';
|
|||||||
|
|
||||||
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
|
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
|
||||||
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
|
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
|
||||||
import { DocumentSigningAuthExternal2FA } from './document-signing-auth-external-2fa';
|
|
||||||
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
|
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
|
||||||
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
|
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
|
||||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
@@ -59,8 +58,15 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset selected auth type when dialog closes
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setSelectedAuthType(validAuthTypes.length === 1 ? validAuthTypes[0] : null);
|
setSelectedAuthType(() => {
|
||||||
|
if (validAuthTypes.length === 1) {
|
||||||
|
return validAuthTypes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpenChange(value);
|
onOpenChange(value);
|
||||||
@@ -117,7 +123,6 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
|
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
|
||||||
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
|
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
|
||||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</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>)
|
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,9 +132,6 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
|
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
|
||||||
.with(DocumentAuth.PASSKEY, () => <Trans>Use your passkey for authentication</Trans>)
|
.with(DocumentAuth.PASSKEY, () => <Trans>Use your passkey for authentication</Trans>)
|
||||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>Enter your 2FA code</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>)
|
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,13 +169,6 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
onReauthFormSubmit={onReauthFormSubmit}
|
onReauthFormSubmit={onReauthFormSubmit}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with({ documentAuthType: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, () => (
|
|
||||||
<DocumentSigningAuthExternal2FA
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
onReauthFormSubmit={onReauthFormSubmit}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
-223
@@ -1,223 +0,0 @@
|
|||||||
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;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocumentSigningAuthProviderProps = {
|
export interface DocumentSigningAuthProviderProps {
|
||||||
documentAuthOptions: Envelope['authOptions'];
|
documentAuthOptions: Envelope['authOptions'];
|
||||||
recipient: SigningAuthRecipient;
|
recipient: SigningAuthRecipient;
|
||||||
isDirectTemplate?: boolean;
|
isDirectTemplate?: boolean;
|
||||||
user?: SessionUser | null;
|
user?: SessionUser | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const DocumentSigningAuthProvider = ({
|
export const DocumentSigningAuthProvider = ({
|
||||||
documentAuthOptions: initialDocumentAuthOptions,
|
documentAuthOptions: initialDocumentAuthOptions,
|
||||||
@@ -169,12 +169,12 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [passkeyData.passkeys]);
|
}, [passkeyData.passkeys]);
|
||||||
|
|
||||||
const authMethodsRequiringLogin = derivedRecipientActionAuth?.filter(
|
// Assume that a user must be logged in for any auth requirements.
|
||||||
(method) => method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isAuthRedirectRequired = Boolean(
|
const isAuthRedirectRequired = Boolean(
|
||||||
authMethodsRequiringLogin && authMethodsRequiringLogin.length > 0 && user?.email !== recipient.email,
|
derivedRecipientActionAuth &&
|
||||||
|
derivedRecipientActionAuth.length > 0 &&
|
||||||
|
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
|
||||||
|
user?.email !== recipient.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchPasskeys = async () => {
|
const refetchPasskeys = async () => {
|
||||||
|
|||||||
@@ -106,12 +106,8 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
|||||||
}))
|
}))
|
||||||
.with(undefined, () => undefined)
|
.with(undefined, () => undefined)
|
||||||
.with(
|
.with(
|
||||||
P.union(
|
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
|
||||||
DocumentAuth.PASSKEY,
|
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
|
||||||
DocumentAuth.TWO_FACTOR_AUTH,
|
|
||||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
|
||||||
DocumentAuth.PASSWORD,
|
|
||||||
),
|
|
||||||
() => 'NOT_SUPPORTED' as const,
|
() => 'NOT_SUPPORTED' as const,
|
||||||
)
|
)
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||||
|
import {
|
||||||
|
getQuotaUsagePercent,
|
||||||
|
isQuotaExceeded,
|
||||||
|
isQuotaNearing,
|
||||||
|
normalizeCapacityLimit,
|
||||||
|
} from '@documenso/lib/universal/quota-usage';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import type { BadgeProps } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Progress } from '@documenso/ui/primitives/progress';
|
import { Progress } from '@documenso/ui/primitives/progress';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
||||||
import { useState } from 'react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useId, useState } from 'react';
|
||||||
|
|
||||||
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
|
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
|
||||||
|
|
||||||
|
type CapacityUsage = {
|
||||||
|
members: number;
|
||||||
|
teams: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UsageRow = {
|
||||||
|
counter: 'document' | 'email' | 'api';
|
||||||
|
label: ReactNode;
|
||||||
|
icon: LucideIcon;
|
||||||
|
used: number;
|
||||||
|
effectiveLimit: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
type OrganisationUsagePanelProps = {
|
type OrganisationUsagePanelProps = {
|
||||||
organisationId: string;
|
organisationId: string;
|
||||||
monthlyStats: Pick<
|
monthlyStats: Pick<
|
||||||
@@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = {
|
|||||||
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
|
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
|
||||||
>[];
|
>[];
|
||||||
organisationClaim: OrganisationClaim;
|
organisationClaim: OrganisationClaim;
|
||||||
|
capacityUsage?: CapacityUsage;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UsageCardState = {
|
||||||
|
status: {
|
||||||
|
label: ReactNode;
|
||||||
|
variant: NonNullable<BadgeProps['variant']>;
|
||||||
|
};
|
||||||
|
percent: number;
|
||||||
|
hasFiniteLimit: boolean;
|
||||||
|
progressClassName: string;
|
||||||
|
subtext: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UsageCardStateOptions = {
|
||||||
|
used: number;
|
||||||
|
limit: number | null | undefined;
|
||||||
|
footnote?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => {
|
||||||
|
const percent = getQuotaUsagePercent(used, limit ?? null);
|
||||||
|
const hasFiniteLimit = Boolean(limit && limit > 0);
|
||||||
|
|
||||||
|
if (limit === null || limit === undefined) {
|
||||||
|
return {
|
||||||
|
status: { label: <Trans>Unlimited</Trans>, variant: 'neutral' },
|
||||||
|
percent,
|
||||||
|
hasFiniteLimit,
|
||||||
|
progressClassName: '',
|
||||||
|
subtext: footnote ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit === 0) {
|
||||||
|
return {
|
||||||
|
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
|
||||||
|
percent,
|
||||||
|
hasFiniteLimit,
|
||||||
|
progressClassName: '',
|
||||||
|
subtext: footnote ?? <Trans>Resource blocked</Trans>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (used > limit) {
|
||||||
|
return {
|
||||||
|
status: { label: <Trans>Exceeded</Trans>, variant: 'destructive' },
|
||||||
|
percent,
|
||||||
|
hasFiniteLimit,
|
||||||
|
progressClassName: '[&>div]:bg-destructive',
|
||||||
|
subtext: footnote ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQuotaExceeded(limit, used)) {
|
||||||
|
return {
|
||||||
|
status: { label: <Trans>Limit reached</Trans>, variant: 'orange' },
|
||||||
|
percent,
|
||||||
|
hasFiniteLimit,
|
||||||
|
progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400',
|
||||||
|
subtext: footnote ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQuotaNearing(limit, used)) {
|
||||||
|
return {
|
||||||
|
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
|
||||||
|
percent,
|
||||||
|
hasFiniteLimit,
|
||||||
|
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
|
||||||
|
subtext: footnote ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: { label: <Trans>Within limit</Trans>, variant: 'default' },
|
||||||
|
percent,
|
||||||
|
hasFiniteLimit,
|
||||||
|
progressClassName: '',
|
||||||
|
subtext: footnote ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type UsageStatCardProps = {
|
||||||
|
label: ReactNode;
|
||||||
|
icon: LucideIcon;
|
||||||
|
used: number;
|
||||||
|
limit: number | null | undefined;
|
||||||
|
/** When true the card is a plain counter with no limit, status or progress. */
|
||||||
|
countOnly?: boolean;
|
||||||
|
footnote?: ReactNode;
|
||||||
|
action?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => {
|
||||||
|
const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col rounded-lg border bg-background p-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!countOnly && (
|
||||||
|
<Badge variant={status.variant} size="small">
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-1 flex-col">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
|
||||||
|
{used.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{hasFiniteLimit ? (
|
||||||
|
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFiniteLimit ? (
|
||||||
|
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
|
||||||
|
|
||||||
|
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OrganisationUsagePanel = ({
|
export const OrganisationUsagePanel = ({
|
||||||
organisationId,
|
organisationId,
|
||||||
monthlyStats,
|
monthlyStats,
|
||||||
organisationClaim,
|
organisationClaim,
|
||||||
|
capacityUsage,
|
||||||
}: OrganisationUsagePanelProps) => {
|
}: OrganisationUsagePanelProps) => {
|
||||||
|
const monthlyUsagePeriodId = useId();
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
|
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
|
||||||
|
|
||||||
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
|
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
|
||||||
@@ -30,86 +193,105 @@ export const OrganisationUsagePanel = ({
|
|||||||
// current period), so only offer the reset action when viewing the current month.
|
// current period), so only offer the reset action when viewing the current month.
|
||||||
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
|
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
|
||||||
|
|
||||||
const rows = [
|
const capacityRows = capacityUsage
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'members',
|
||||||
|
label: <Trans>Members</Trans>,
|
||||||
|
icon: UsersIcon,
|
||||||
|
used: capacityUsage.members,
|
||||||
|
limit: normalizeCapacityLimit(organisationClaim.memberCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'teams',
|
||||||
|
label: <Trans>Teams</Trans>,
|
||||||
|
icon: UsersRoundIcon,
|
||||||
|
used: capacityUsage.teams,
|
||||||
|
limit: normalizeCapacityLimit(organisationClaim.teamCount),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const monthlyRows: UsageRow[] = [
|
||||||
{
|
{
|
||||||
counter: 'document' as const,
|
counter: 'document',
|
||||||
label: <Trans>Documents</Trans>,
|
label: <Trans>Documents</Trans>,
|
||||||
|
icon: FileIcon,
|
||||||
used: selectedStat?.documentCount ?? 0,
|
used: selectedStat?.documentCount ?? 0,
|
||||||
effectiveLimit: organisationClaim.documentQuota,
|
effectiveLimit: organisationClaim.documentQuota,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
counter: 'email' as const,
|
counter: 'email',
|
||||||
label: <Trans>Emails</Trans>,
|
label: <Trans>Emails</Trans>,
|
||||||
|
icon: MailIcon,
|
||||||
used: selectedStat?.emailCount ?? 0,
|
used: selectedStat?.emailCount ?? 0,
|
||||||
effectiveLimit: organisationClaim.emailQuota,
|
effectiveLimit: organisationClaim.emailQuota,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
counter: 'api' as const,
|
counter: 'api',
|
||||||
label: <Trans>API requests</Trans>,
|
label: <Trans>API requests</Trans>,
|
||||||
|
icon: PlugIcon,
|
||||||
used: selectedStat?.apiCount ?? 0,
|
used: selectedStat?.apiCount ?? 0,
|
||||||
effectiveLimit: organisationClaim.apiQuota,
|
effectiveLimit: organisationClaim.apiQuota,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<div className="mt-4 space-y-6">
|
||||||
<div className="flex items-center justify-between gap-2">
|
{capacityRows.length > 0 ? (
|
||||||
<h3 className="font-medium text-sm">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
|
{capacityRows.map((row) => (
|
||||||
</h3>
|
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{monthlyStats.length > 0 && (
|
<div className="space-y-3">
|
||||||
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<SelectTrigger className="w-40">
|
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
|
||||||
<SelectValue />
|
<Trans>Monthly usage</Trans>
|
||||||
</SelectTrigger>
|
</h3>
|
||||||
<SelectContent>
|
|
||||||
{monthlyStats.map((stat) => (
|
|
||||||
<SelectItem key={stat.period} value={stat.period}>
|
|
||||||
{stat.period}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rows.map((row) => {
|
{monthlyStats.length > 0 ? (
|
||||||
const percent =
|
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
||||||
row.effectiveLimit && row.effectiveLimit > 0
|
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
|
||||||
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
|
<SelectValue />
|
||||||
: 0;
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{monthlyStats.map((stat) => (
|
||||||
|
<SelectItem key={stat.period} value={stat.period}>
|
||||||
|
{stat.period}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<div key={row.counter} className="space-y-1">
|
{monthlyRows.map((row) => (
|
||||||
<div className="flex items-center justify-between text-sm">
|
<UsageStatCard
|
||||||
<span>{row.label}</span>
|
key={row.counter}
|
||||||
<span className="text-muted-foreground">
|
label={row.label}
|
||||||
{row.used} /{' '}
|
icon={row.icon}
|
||||||
{match(row.effectiveLimit)
|
used={row.used}
|
||||||
.with(null, () => <Trans>Unlimited</Trans>)
|
limit={row.effectiveLimit}
|
||||||
.with(0, () => <Trans>Blocked</Trans>)
|
action={
|
||||||
.otherwise(String)}
|
selectedStat && isCurrentPeriod ? (
|
||||||
</span>
|
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||||
</div>
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
|
<UsageStatCard
|
||||||
|
label={<Trans>Reports</Trans>}
|
||||||
{selectedStat && isCurrentPeriod && (
|
icon={MailOpenIcon}
|
||||||
<div className="flex w-full justify-end pt-1">
|
used={selectedStat?.emailReports ?? 0}
|
||||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
limit={null}
|
||||||
</div>
|
countOnly
|
||||||
)}
|
footnote={<Trans>Sent this period</Trans>}
|
||||||
</div>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span>
|
|
||||||
<Trans>Reports</Trans>
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { RotateCcwIcon } from 'lucide-react';
|
||||||
import { useRevalidator } from 'react-router';
|
import { useRevalidator } from 'react-router';
|
||||||
|
|
||||||
type OrganisationUsageResetButtonProps = {
|
type OrganisationUsageResetButtonProps = {
|
||||||
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
|
|||||||
loading={isPending}
|
loading={isPending}
|
||||||
onClick={() => reset({ organisationId, counter })}
|
onClick={() => reset({ organisationId, counter })}
|
||||||
>
|
>
|
||||||
|
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
|
||||||
<Trans>Reset</Trans>
|
<Trans>Reset</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
type RateLimitEntryValue = { window: string; max: number };
|
type RateLimitEntryValue = { window: string; max: number };
|
||||||
|
|
||||||
@@ -11,50 +13,153 @@ type RateLimitArrayInputProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
|
||||||
|
|
||||||
|
/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */
|
||||||
|
const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0;
|
||||||
|
|
||||||
|
/** Keep in-progress rows; drop rows that are completely empty. */
|
||||||
|
const persistEntries = (entries: RateLimitEntryValue[]) => {
|
||||||
|
return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput);
|
||||||
|
};
|
||||||
|
|
||||||
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
|
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
|
||||||
const entries = value ?? [];
|
const { t } = useLingui();
|
||||||
|
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(null);
|
||||||
|
|
||||||
|
const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY];
|
||||||
|
|
||||||
|
const getWindowError = (entry: RateLimitEntryValue, index: number) => {
|
||||||
|
const window = entry.window.trim();
|
||||||
|
|
||||||
|
if (!hasEntryInput(entry)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window === '') {
|
||||||
|
return t`Enter a window, e.g. 5m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RATE_LIMIT_WINDOW_REGEX.test(window)) {
|
||||||
|
return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDuplicateWindow = entries.some((otherEntry, otherIndex) => {
|
||||||
|
return otherIndex !== index && otherEntry.window.trim() === window;
|
||||||
|
});
|
||||||
|
|
||||||
|
return isDuplicateWindow ? t`Use a unique window for each rate limit` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMaxError = (entry: RateLimitEntryValue) => {
|
||||||
|
if (!hasEntryInput(entry)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.max > 0 ? null : t`Enter a max request count greater than 0`;
|
||||||
|
};
|
||||||
|
|
||||||
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
|
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
|
||||||
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
if (index >= value.length) {
|
||||||
onChange(next);
|
const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
|
||||||
|
|
||||||
|
if (hasEntryInput(nextDraftEntry)) {
|
||||||
|
onChange(persistEntries([...value, nextDraftEntry]));
|
||||||
|
setDraftEntry(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraftEntry(nextDraftEntry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
||||||
|
onChange(persistEntries(next));
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEntry = (index: number) => {
|
const removeEntry = (index: number) => {
|
||||||
onChange(entries.filter((_, i) => i !== index));
|
if (index >= value.length) {
|
||||||
|
setDraftEntry(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = value.filter((_, i) => i !== index);
|
||||||
|
onChange(persistEntries(next));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addEntry = () => {
|
const addEntry = () => {
|
||||||
onChange([...entries, { window: '5m', max: 100 }]);
|
setDraftEntry(EMPTY_ENTRY);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
|
||||||
|
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{entries.map((entry, index) => (
|
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||||
<div key={index} className="flex items-center gap-2">
|
<span className="w-20 shrink-0">
|
||||||
<Input
|
<Trans>Window</Trans>
|
||||||
className="w-24"
|
</span>
|
||||||
placeholder="5m"
|
<span className="flex-1">
|
||||||
value={entry.window}
|
<Trans>Max requests</Trans>
|
||||||
disabled={disabled}
|
</span>
|
||||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
<span className="w-9 shrink-0" aria-hidden="true" />
|
||||||
/>
|
</div>
|
||||||
<Input
|
|
||||||
className="w-32"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={entry.max}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
|
||||||
/>
|
|
||||||
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
|
|
||||||
<Trash2Icon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
|
{entries.map((entry, index) => {
|
||||||
|
const windowError = getWindowError(entry, index);
|
||||||
|
const maxError = getMaxError(entry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="w-20 shrink-0"
|
||||||
|
placeholder="5m"
|
||||||
|
value={entry.window}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={Boolean(windowError)}
|
||||||
|
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="100"
|
||||||
|
value={entry.max || ''}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={Boolean(maxError)}
|
||||||
|
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t`Remove rate limit`}
|
||||||
|
onClick={() => removeEntry(index)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
|
||||||
|
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full border-dashed"
|
||||||
|
disabled={isAddDisabled}
|
||||||
|
onClick={addEntry}
|
||||||
|
>
|
||||||
<PlusIcon className="mr-2 h-4 w-4" />
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
<Trans>Add rate limit</Trans>
|
<Trans>Add rate limit window</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ZUpdatePasskeySchema = z.object({
|
const ZUpdatePasskeySchema = z.object({
|
||||||
name: z.string(),
|
name: ZNameSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
||||||
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
|
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
@@ -30,7 +31,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
|
||||||
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
|
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -42,7 +43,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
|
|||||||
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
||||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||||
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
|
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
|
||||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
|
||||||
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
||||||
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
<GenericOrganisationAdminForm organisation={organisation} />
|
<GenericOrganisationAdminForm organisation={organisation} />
|
||||||
|
|
||||||
<div className="mt-6 rounded-lg border p-4">
|
<SettingsHeader
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
title={t`Organisation usage`}
|
||||||
<div>
|
subtitle={t`Current usage against organisation limits.`}
|
||||||
<p className="font-medium text-sm">
|
className="mt-6"
|
||||||
<Trans>Organisation usage</Trans>
|
hideDivider
|
||||||
</p>
|
/>
|
||||||
<p className="mt-1 text-muted-foreground text-sm">
|
|
||||||
<Trans>Current usage against organisation limits.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
<OrganisationUsagePanel
|
||||||
<DetailsCard label={<Trans>Members</Trans>}>
|
organisationId={organisation.id}
|
||||||
<DetailsValue>
|
monthlyStats={organisation.monthlyStats}
|
||||||
{organisation.members.length} /{' '}
|
organisationClaim={organisation.organisationClaim}
|
||||||
{organisation.organisationClaim.memberCount === 0
|
capacityUsage={{
|
||||||
? t`Unlimited`
|
members: organisation.members.length,
|
||||||
: organisation.organisationClaim.memberCount}
|
teams: organisation.teams.length,
|
||||||
</DetailsValue>
|
}}
|
||||||
</DetailsCard>
|
/>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Teams</Trans>}>
|
|
||||||
<DetailsValue>
|
|
||||||
{organisation.teams.length} /{' '}
|
|
||||||
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
|
|
||||||
</DetailsValue>
|
|
||||||
</DetailsCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<OrganisationUsagePanel
|
|
||||||
organisationId={organisation.id}
|
|
||||||
monthlyStats={organisation.monthlyStats}
|
|
||||||
organisationClaim={organisation.organisationClaim}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 rounded-lg border p-4">
|
<div className="mt-6 rounded-lg border p-4">
|
||||||
<Accordion type="single" collapsible>
|
<Accordion type="single" collapsible>
|
||||||
<AccordionItem value="global-settings" className="border-b-0">
|
<AccordionItem value="global-settings" className="border-b-0">
|
||||||
<AccordionTrigger className="py-0">
|
<AccordionTrigger className="py-0">
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-medium text-sm">
|
<p className="font-semibold text-base">
|
||||||
<Trans>Global Settings</Trans>
|
<Trans>Global Settings</Trans>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-normal text-muted-foreground text-sm">
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
<Trans>Default settings applied to this organisation.</Trans>
|
<Trans>Default settings applied to this organisation.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
className="mt-16"
|
className="mt-16"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
<Alert
|
||||||
|
className={cn(
|
||||||
|
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
|
||||||
|
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
|
||||||
|
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
|
||||||
|
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
|
||||||
|
)}
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
<div className="mb-4 sm:mb-0">
|
<div className="mb-4 sm:mb-0">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Subscription</Trans>
|
<Trans>Subscription</Trans>
|
||||||
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
<AlertDescription className="mr-2">
|
<AlertDescription className="mr-2">
|
||||||
{organisation.subscription ? (
|
{organisation.subscription ? (
|
||||||
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
|
<span className="flex items-center gap-2">
|
||||||
|
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
<Trans>No subscription found</Trans>
|
<Trans>No subscription found</Trans>
|
||||||
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="bg-background"
|
||||||
loading={isCreatingStripeCustomer}
|
loading={isCreatingStripeCustomer}
|
||||||
onClick={async () => createStripeCustomer({ organisationId })}
|
onClick={async () => createStripeCustomer({ organisationId })}
|
||||||
>
|
>
|
||||||
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
{organisation.customerId && !organisation.subscription && (
|
{organisation.customerId && !organisation.subscription && (
|
||||||
<div>
|
<div>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" className="bg-background" asChild>
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
|
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
|
||||||
@@ -383,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
<AdminOrganisationSyncSubscriptionDialog
|
<AdminOrganisationSyncSubscriptionDialog
|
||||||
organisationId={organisationId}
|
organisationId={organisationId}
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="outline">
|
<Button variant="outline" className="bg-background">
|
||||||
<Trans>Sync Stripe subscription</Trans>
|
<Trans>Sync Stripe subscription</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" className="bg-background" asChild>
|
||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
|
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
|
||||||
@@ -406,21 +398,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
|||||||
|
|
||||||
<div className="mt-16 space-y-10">
|
<div className="mt-16 space-y-10">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium text-sm leading-none">
|
<h3 className="font-semibold text-base">
|
||||||
<Trans>Organisation Members</Trans>
|
<Trans>Organisation Members</Trans>
|
||||||
</label>
|
</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
<Trans>People with access to this organisation.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="my-2">
|
<div className="mt-3">
|
||||||
<DataTable columns={organisationMembersColumns} data={organisation.members} />
|
<DataTable columns={organisationMembersColumns} data={organisation.members} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-medium text-sm leading-none">
|
<h3 className="font-semibold text-base">
|
||||||
<Trans>Organisation Teams</Trans>
|
<Trans>Organisation Teams</Trans>
|
||||||
</label>
|
</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
<Trans>Teams that belong to this organisation.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="my-2">
|
<div className="mt-3">
|
||||||
<DataTable columns={teamsColumns} data={organisation.teams} />
|
<DataTable columns={teamsColumns} data={organisation.teams} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
<FormLabel className="flex items-center">
|
<FormLabel className="flex items-center">
|
||||||
<Trans>Inherited subscription claim</Trans>
|
<Trans>Inherited subscription claim</Trans>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
|
||||||
<Input disabled {...field} />
|
{field.value ? (
|
||||||
</FormControl>
|
<span className="font-mono text-foreground">{field.value}</span>
|
||||||
<FormMessage />
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<Trans>No inherited claim</Trans>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="claims.teamCount"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="claims.teamCount"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem>
|
||||||
<Trans>Team Count</Trans>
|
<FormLabel>
|
||||||
</FormLabel>
|
<Trans>Team Count</Trans>
|
||||||
<FormControl>
|
</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
type="number"
|
<Input
|
||||||
min={0}
|
type="number"
|
||||||
{...field}
|
min={0}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
{...field}
|
||||||
/>
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
</FormControl>
|
/>
|
||||||
<FormDescription>
|
</FormControl>
|
||||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
<FormDescription>
|
||||||
</FormDescription>
|
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
||||||
<FormMessage />
|
</FormDescription>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="claims.memberCount"
|
name="claims.memberCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Member Count</Trans>
|
<Trans>Member Count</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="claims.envelopeItemCount"
|
name="claims.envelopeItemCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Envelope Item Count</Trans>
|
<Trans>Envelope Item Count</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="claims.recipientCount"
|
name="claims.recipientCount"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Recipient Count</Trans>
|
<Trans>Recipient Count</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>
|
<h3 className="font-semibold text-base">
|
||||||
<Trans>Feature Flags</Trans>
|
<Trans>Feature Flags</Trans>
|
||||||
</FormLabel>
|
</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
|
<Trans>Capabilities enabled for this organisation.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
<div className="mt-3 space-y-2 rounded-md border p-4">
|
||||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
|
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
|
||||||
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||||
|
|
||||||
|
|||||||
@@ -287,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
|
<AdminGlobalSettingsSection
|
||||||
|
settings={team.teamGlobalSettings}
|
||||||
|
inheritedSettings={team.organisation.organisationGlobalSettings}
|
||||||
|
isTeam
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
|
|
||||||
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
|
||||||
|
|
||||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||||
|
|
||||||
let uploadedBrandingLogo: string | undefined;
|
// Upload (or clear) the logo through the dedicated, server-validated route.
|
||||||
|
if (brandingLogo instanceof File || brandingLogo === null) {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
if (brandingLogo) {
|
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
|
||||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty the branding logo if the user unsets it.
|
if (brandingLogo instanceof File) {
|
||||||
if (brandingLogo === null) {
|
formData.append('brandingLogo', brandingLogo);
|
||||||
uploadedBrandingLogo = '';
|
}
|
||||||
|
|
||||||
|
await updateOrganisationBrandingLogo(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateOrganisationSettings({
|
const result = await updateOrganisationSettings({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
data: {
|
data: {
|
||||||
brandingEnabled: brandingEnabled ?? undefined,
|
brandingEnabled: brandingEnabled ?? undefined,
|
||||||
brandingLogo: uploadedBrandingLogo,
|
|
||||||
brandingUrl,
|
brandingUrl,
|
||||||
brandingCompanyDetails,
|
brandingCompanyDetails,
|
||||||
brandingColors,
|
brandingColors,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
|
|||||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
|
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -28,7 +29,6 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
|
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
OrganisationMembersMultiSelectCombobox,
|
OrganisationMembersMultiSelectCombobox,
|
||||||
} from '~/components/general/organisation-members-multiselect-combobox';
|
} from '~/components/general/organisation-members-multiselect-combobox';
|
||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
|
|
||||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||||
|
|
||||||
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
|
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
|
||||||
@@ -113,7 +112,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ZUpdateOrganisationGroupFormSchema = z.object({
|
const ZUpdateOrganisationGroupFormSchema = z.object({
|
||||||
name: z.string().min(1, msg`Name is required`.id),
|
name: ZNameSchema,
|
||||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||||
memberIds: z.array(z.string()),
|
memberIds: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -38,6 +37,7 @@ export default function TeamsSettingsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||||
|
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
|
||||||
|
|
||||||
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
|
||||||
|
|
||||||
@@ -48,22 +48,23 @@ export default function TeamsSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||||
|
|
||||||
let uploadedBrandingLogo: string | undefined;
|
// Upload (or clear) the logo through the dedicated, server-validated route.
|
||||||
|
if (brandingLogo instanceof File || brandingLogo === null) {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
if (brandingLogo) {
|
formData.append('payload', JSON.stringify({ teamId: team.id }));
|
||||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty the branding logo if the user unsets it.
|
if (brandingLogo instanceof File) {
|
||||||
if (brandingLogo === null) {
|
formData.append('brandingLogo', brandingLogo);
|
||||||
uploadedBrandingLogo = '';
|
}
|
||||||
|
|
||||||
|
await updateTeamBrandingLogo(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await updateTeamSettings({
|
const result = await updateTeamSettings({
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
data: {
|
data: {
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
brandingLogo: uploadedBrandingLogo,
|
|
||||||
brandingUrl: brandingUrl || null,
|
brandingUrl: brandingUrl || null,
|
||||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||||
brandingColors,
|
brandingColors,
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
let authLevel = match(actionAuthMethod)
|
let authLevel = match(actionAuthMethod)
|
||||||
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
|
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
|
||||||
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor 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('PASSWORD', () => _(msg`Password Re-Authentication`))
|
||||||
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
|
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
|
||||||
.with('EXPLICIT_NONE', () => _(msg`Email`))
|
.with('EXPLICIT_NONE', () => _(msg`Email`))
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
"@lingui/react": "^5.6.0",
|
"@lingui/react": "^5.6.0",
|
||||||
"@oslojs/crypto": "^1.0.1",
|
"@oslojs/crypto": "^1.0.1",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@oslojs/encoding": "^1.1.0",
|
||||||
"@react-router/node": "^7.12.0",
|
"@react-router/node": "^7.18.1",
|
||||||
"@react-router/serve": "^7.12.0",
|
"@react-router/serve": "^7.18.1",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@tanstack/react-query": "5.90.10",
|
"@tanstack/react-query": "5.90.10",
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
|
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
|
||||||
"@lingui/vite-plugin": "^5.6.0",
|
"@lingui/vite-plugin": "^5.6.0",
|
||||||
"@react-router/dev": "^7.12.0",
|
"@react-router/dev": "^7.18.1",
|
||||||
"@react-router/remix-routes-option-adapter": "^7.12.0",
|
"@react-router/remix-routes-option-adapter": "^7.18.1",
|
||||||
"@rollup/plugin-babel": "^6.1.0",
|
"@rollup/plugin-babel": "^6.1.0",
|
||||||
"@rollup/plugin-commonjs": "^28.0.9",
|
"@rollup/plugin-commonjs": "^28.0.9",
|
||||||
"@rollup/plugin-json": "^6.1.0",
|
"@rollup/plugin-json": "^6.1.0",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
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 { prisma } from '@documenso/prisma';
|
||||||
import { sValidator } from '@hono/standard-validator';
|
import { sValidator } from '@hono/standard-validator';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
|
|||||||
import type { HonoEnv } from '../../router';
|
import type { HonoEnv } from '../../router';
|
||||||
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
|
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
|
||||||
import {
|
import {
|
||||||
isAllowedUploadContentType,
|
|
||||||
type TGetPresignedPostUrlResponse,
|
|
||||||
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
|
||||||
ZGetEnvelopeItemFileRequestParamsSchema,
|
ZGetEnvelopeItemFileRequestParamsSchema,
|
||||||
ZGetEnvelopeItemFileRequestQuerySchema,
|
ZGetEnvelopeItemFileRequestQuerySchema,
|
||||||
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
|
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
|
||||||
ZGetEnvelopeItemFileTokenRequestParamsSchema,
|
ZGetEnvelopeItemFileTokenRequestParamsSchema,
|
||||||
ZGetPresignedPostUrlRequestSchema,
|
|
||||||
ZUploadPdfRequestSchema,
|
ZUploadPdfRequestSchema,
|
||||||
} from './files.types';
|
} from './files.types';
|
||||||
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
|
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
|
||||||
@@ -61,29 +57,6 @@ export const filesRoute = new Hono<HonoEnv>()
|
|||||||
return c.json({ error: 'Upload failed' }, 500);
|
return c.json({ error: 'Upload failed' }, 500);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
|
|
||||||
const userId = await resolveFileUploadUserId(c);
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return c.json({ error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { fileName, contentType } = c.req.valid('json');
|
|
||||||
|
|
||||||
if (!isAllowedUploadContentType(contentType)) {
|
|
||||||
return c.json({ error: 'Unsupported content type' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
|
|
||||||
|
|
||||||
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.get(
|
.get(
|
||||||
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
|
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
|
||||||
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
|
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
|
||||||
|
|||||||
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
|
|||||||
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
|
||||||
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
|
||||||
|
|
||||||
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
|
|
||||||
|
|
||||||
export const isAllowedUploadContentType = (contentType: string): boolean => {
|
|
||||||
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
|
|
||||||
|
|
||||||
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ZGetPresignedPostUrlRequestSchema = z.object({
|
|
||||||
fileName: z.string().min(1),
|
|
||||||
contentType: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZGetPresignedPostUrlResponseSchema = z.object({
|
|
||||||
key: z.string().min(1),
|
|
||||||
url: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
|
|
||||||
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
|
|
||||||
|
|
||||||
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
|
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
|
||||||
envelopeId: z.string().min(1),
|
envelopeId: z.string().min(1),
|
||||||
envelopeItemId: z.string().min(1),
|
envelopeItemId: z.string().min(1),
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
|
|||||||
|
|
||||||
// Files route.
|
// Files route.
|
||||||
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
|
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
|
||||||
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
|
|
||||||
app.route('/api/files', filesRoute);
|
app.route('/api/files', filesRoute);
|
||||||
|
|
||||||
// AI route.
|
// AI route.
|
||||||
|
|||||||
Generated
+2048
-1264
File diff suppressed because it is too large
Load Diff
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
|
|||||||
expect(res.status()).toBe(401);
|
expect(res.status()).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
|
|
||||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.ok()).toBeFalsy();
|
|
||||||
expect(res.status()).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
|
|
||||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: 'Bearer not-a-real-token',
|
|
||||||
},
|
|
||||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.ok()).toBeFalsy();
|
|
||||||
expect(res.status()).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
|
|
||||||
const { user, team } = await seedUser();
|
|
||||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
|
||||||
|
|
||||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${presignToken}`,
|
|
||||||
},
|
|
||||||
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Authenticated, but the content type is not on the allow-list.
|
|
||||||
expect(res.ok()).toBeFalsy();
|
|
||||||
expect(res.status()).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
|
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const makePng = async (width = 1200, height = 1200) =>
|
||||||
|
sharp({
|
||||||
|
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
test.describe('optimiseBrandingLogo', () => {
|
||||||
|
test('re-encodes a valid image to a PNG buffer', async () => {
|
||||||
|
const input = await makePng();
|
||||||
|
|
||||||
|
const output = await optimiseBrandingLogo(input);
|
||||||
|
|
||||||
|
const metadata = await sharp(output).metadata();
|
||||||
|
|
||||||
|
expect(metadata.format).toBe('png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bounds the image to a maximum of 512px on its largest side', async () => {
|
||||||
|
const input = await makePng(2000, 1000);
|
||||||
|
|
||||||
|
const output = await optimiseBrandingLogo(input);
|
||||||
|
|
||||||
|
const metadata = await sharp(output).metadata();
|
||||||
|
|
||||||
|
expect(metadata.width).toBeLessThanOrEqual(512);
|
||||||
|
expect(metadata.height).toBeLessThanOrEqual(512);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects input that is not a valid image', async () => {
|
||||||
|
await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, type Page, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { apiSignin } from './fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const LOGO_PATH = path.join(__dirname, '../../assets/logo.png');
|
||||||
|
|
||||||
|
type MultipartFile = { name: string; mimeType: string; buffer: Buffer };
|
||||||
|
|
||||||
|
const enableBrandingAndUpload = async (page: Page) => {
|
||||||
|
// Enable custom branding so the file input is no longer disabled.
|
||||||
|
await page.getByTestId('enable-branding').click();
|
||||||
|
await page.getByRole('option', { name: 'Yes' }).click();
|
||||||
|
|
||||||
|
// Upload the logo file through the real multipart route.
|
||||||
|
await page.locator('input[type="file"]').setInputFiles(LOGO_PATH);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
|
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST a logo straight to the dedicated multipart tRPC route using the
|
||||||
|
* authenticated browser cookies. This bypasses the client-side form validation,
|
||||||
|
* which is the only way to exercise the server-side image validation /
|
||||||
|
* sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate.
|
||||||
|
*/
|
||||||
|
const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => {
|
||||||
|
const multipart: Record<string, string | MultipartFile> = {
|
||||||
|
payload: JSON.stringify({ organisationId }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
multipart.brandingLogo = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await page
|
||||||
|
.context()
|
||||||
|
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant the organisation the custom-branding entitlement. The positive branding
|
||||||
|
* flows require it whenever billing is enabled; with billing disabled the gate is
|
||||||
|
* bypassed, so this keeps these tests valid in both modes.
|
||||||
|
*/
|
||||||
|
const grantCustomBranding = async (organisationClaimId: string) => {
|
||||||
|
await prisma.organisationClaim.update({
|
||||||
|
where: { id: organisationClaimId },
|
||||||
|
data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => {
|
||||||
|
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||||
|
|
||||||
|
await grantCustomBranding(organisation.organisationClaim.id);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enableBrandingAndUpload(page);
|
||||||
|
|
||||||
|
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||||
|
where: { id: organisation.organisationGlobalSettingsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(settings.brandingLogo).toBeTruthy();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(settings.brandingLogo);
|
||||||
|
expect(parsed).toHaveProperty('type');
|
||||||
|
expect(parsed).toHaveProperty('data');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => {
|
||||||
|
const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||||
|
|
||||||
|
await grantCustomBranding(organisation.organisationClaim.id);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/settings/branding`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enableBrandingAndUpload(page);
|
||||||
|
|
||||||
|
// TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it
|
||||||
|
// through the team relation.
|
||||||
|
const teamWithSettings = await prisma.team.findUniqueOrThrow({
|
||||||
|
where: { id: team.id },
|
||||||
|
include: { teamGlobalSettings: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? '');
|
||||||
|
expect(parsed).toHaveProperty('type');
|
||||||
|
expect(parsed).toHaveProperty('data');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => {
|
||||||
|
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||||
|
|
||||||
|
await grantCustomBranding(organisation.organisationClaim.id);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enableBrandingAndUpload(page);
|
||||||
|
|
||||||
|
// Confirm the logo was stored before we clear it.
|
||||||
|
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||||
|
where: { id: organisation.organisationGlobalSettingsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(settings.brandingLogo).toBeTruthy();
|
||||||
|
|
||||||
|
// Remove the logo and save again.
|
||||||
|
await page.getByRole('button', { name: 'Remove' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||||
|
|
||||||
|
// Clearing the logo persists an empty string via the dedicated route.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||||
|
where: { id: organisation.organisationGlobalSettingsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated.brandingLogo;
|
||||||
|
})
|
||||||
|
.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => {
|
||||||
|
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||||
|
|
||||||
|
await grantCustomBranding(organisation.organisationClaim.id);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Positive control: a genuine PNG is accepted and stored. This also proves the
|
||||||
|
// direct multipart request shape matches what the route expects.
|
||||||
|
const validResponse = await postOrganisationBrandingLogo(page, organisation.id, {
|
||||||
|
name: 'logo.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: fs.readFileSync(LOGO_PATH),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validResponse.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||||
|
where: { id: organisation.organisationGlobalSettingsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(afterValid.brandingLogo).toBeTruthy();
|
||||||
|
|
||||||
|
// Bytes that pass the MIME/size allowlist but are not a real image must be
|
||||||
|
// rejected by the server (the `sharp` re-encode) without changing stored state.
|
||||||
|
const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, {
|
||||||
|
name: 'fake.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: Buffer.from('this is definitely not a valid png'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invalidResponse.ok()).toBeFalsy();
|
||||||
|
expect(invalidResponse.status()).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(invalidResponse.status()).toBeLessThan(500);
|
||||||
|
|
||||||
|
const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||||
|
where: { id: organisation.organisationGlobalSettingsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The previously stored, valid logo is left untouched by the rejected upload.
|
||||||
|
expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => {
|
||||||
|
// The entitlement is only enforced when billing is enabled; with billing off
|
||||||
|
// the check is intentionally skipped server-side, so this can't be exercised.
|
||||||
|
test.skip(
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true',
|
||||||
|
'Entitlement is only enforced when billing is enabled.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seeded organisations have no `allowCustomBranding` claim flag.
|
||||||
|
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/o/${organisation.url}/settings/branding`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await postOrganisationBrandingLogo(page, organisation.id, {
|
||||||
|
name: 'logo.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: fs.readFileSync(LOGO_PATH),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.ok()).toBeFalsy();
|
||||||
|
|
||||||
|
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
|
||||||
|
where: { id: organisation.organisationGlobalSettingsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(settings.brandingLogo).toBeFalsy();
|
||||||
|
});
|
||||||
@@ -142,3 +142,38 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web
|
|||||||
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||||
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { user, team, organisation } = await seedUser();
|
||||||
|
|
||||||
|
await enableOrganisationBranding({
|
||||||
|
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
teamId: team.id,
|
||||||
|
recipients: ['enabled-disabled-branding-signer@test.documenso.com'],
|
||||||
|
fields: [FieldType.SIGNATURE],
|
||||||
|
updateDocumentOptions: { internalVersion: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Branding enabled → the custom logo is rendered on the signing page.
|
||||||
|
await page.goto(`/sign/${recipients[0].token}`);
|
||||||
|
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||||
|
|
||||||
|
// Disable branding while keeping the stored logo (the team inherits this).
|
||||||
|
await prisma.organisationGlobalSettings.update({
|
||||||
|
where: { id: organisation.organisationGlobalSettingsId },
|
||||||
|
data: { brandingEnabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Branding disabled → the custom logo is gone and the Documenso fallback
|
||||||
|
// (an internal link to "/") is shown instead.
|
||||||
|
await page.goto(`/sign/${recipients[0].token}`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
||||||
|
await expect(page.locator('a[href="/"]').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
import MailChecker from 'mailchecker';
|
import MailChecker from 'mailchecker';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { env } from '../utils/env';
|
import { env } from '../utils/env';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
|
||||||
|
|
||||||
export const SALT_ROUNDS = 12;
|
export const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
export const URL_PATTERN = /https?:\/\/|www\./i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared name schema that disallows URLs to prevent phishing via email rendering.
|
|
||||||
*/
|
|
||||||
export const ZNameSchema = z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(3, { message: 'Please enter a valid name.' })
|
|
||||||
.max(255, { message: 'Name cannot be more than 255 characters.' })
|
|
||||||
.refine((value) => !URL_PATTERN.test(value), {
|
|
||||||
message: 'Name cannot contain URLs.',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
||||||
DOCUMENSO: 'Documenso',
|
DOCUMENSO: 'Documenso',
|
||||||
GOOGLE: 'Google',
|
GOOGLE: 'Google',
|
||||||
|
|||||||
@@ -9,3 +9,13 @@
|
|||||||
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
||||||
*/
|
*/
|
||||||
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
|
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branding logo upload constraints. Enforced server-side at the TRPC request
|
||||||
|
* boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX.
|
||||||
|
*/
|
||||||
|
export const BRANDING_LOGO_MAX_SIZE_MB = 5;
|
||||||
|
|
||||||
|
export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|||||||
@@ -26,21 +26,8 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
|
|||||||
key: DocumentAuth.PASSWORD,
|
key: DocumentAuth.PASSWORD,
|
||||||
value: msg`Require password`,
|
value: msg`Require password`,
|
||||||
},
|
},
|
||||||
[DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH]: {
|
|
||||||
key: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
|
||||||
value: msg`Require external 2FA`,
|
|
||||||
},
|
|
||||||
[DocumentAuth.EXPLICIT_NONE]: {
|
[DocumentAuth.EXPLICIT_NONE]: {
|
||||||
key: DocumentAuth.EXPLICIT_NONE,
|
key: DocumentAuth.EXPLICIT_NONE,
|
||||||
value: msg`None (Overrides global settings)`,
|
value: msg`None (Overrides global settings)`,
|
||||||
},
|
},
|
||||||
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
|
} 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;
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { putFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
|
import { optimiseBrandingLogo } from '../../utils/images/logo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate, sanitise and store an uploaded branding logo. Returns the
|
||||||
|
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
|
||||||
|
* column (the same format the serving endpoints already expect).
|
||||||
|
*/
|
||||||
|
export const buildBrandingLogoData = async (file: File): Promise<string> => {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: 'The branding logo must be a valid image file.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentData = await putFileServerSide({
|
||||||
|
name: 'branding-logo.png',
|
||||||
|
type: 'image/png',
|
||||||
|
arrayBuffer: async () => Promise.resolve(optimised),
|
||||||
|
});
|
||||||
|
|
||||||
|
return JSON.stringify(documentData);
|
||||||
|
};
|
||||||
@@ -114,28 +114,11 @@ export const completeDocumentWithToken = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check ACCESS AUTH 2FA validation during document completion
|
// Check ACCESS AUTH 2FA validation during document completion
|
||||||
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
documentAuth: envelope.authOptions,
|
documentAuth: envelope.authOptions,
|
||||||
recipientAuth: recipient.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 (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
|
||||||
if (!accessAuthOptions) {
|
if (!accessAuthOptions) {
|
||||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ type IsRecipientAuthorizedOptions = {
|
|||||||
* using the user ID.
|
* using the user ID.
|
||||||
*/
|
*/
|
||||||
authOptions?: TDocumentAuthMethods;
|
authOptions?: TDocumentAuthMethods;
|
||||||
recipientToken?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUserByEmail = async (email: string) => {
|
const getUserByEmail = async (email: string) => {
|
||||||
@@ -57,7 +56,6 @@ export const isRecipientAuthorized = async ({
|
|||||||
recipient,
|
recipient,
|
||||||
userId,
|
userId,
|
||||||
authOptions,
|
authOptions,
|
||||||
recipientToken,
|
|
||||||
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
|
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
|
||||||
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||||
documentAuth: documentAuthOptions,
|
documentAuth: documentAuthOptions,
|
||||||
@@ -165,21 +163,6 @@ export const isRecipientAuthorized = async ({
|
|||||||
password,
|
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 }, () => {
|
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export type ValidateFieldAuthOptions = {
|
|||||||
field: Field;
|
field: Field;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
authOptions?: TRecipientActionAuth;
|
authOptions?: TRecipientActionAuth;
|
||||||
recipientToken?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +24,6 @@ export const validateFieldAuth = async ({
|
|||||||
field,
|
field,
|
||||||
userId,
|
userId,
|
||||||
authOptions,
|
authOptions,
|
||||||
recipientToken,
|
|
||||||
}: ValidateFieldAuthOptions) => {
|
}: ValidateFieldAuthOptions) => {
|
||||||
// Override all non-signature fields to not require any auth.
|
// Override all non-signature fields to not require any auth.
|
||||||
if (field.type !== FieldType.SIGNATURE) {
|
if (field.type !== FieldType.SIGNATURE) {
|
||||||
@@ -38,7 +36,6 @@ export const validateFieldAuth = async ({
|
|||||||
recipient,
|
recipient,
|
||||||
userId,
|
userId,
|
||||||
authOptions,
|
authOptions,
|
||||||
recipientToken,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ export const signFieldWithToken = async ({
|
|||||||
field,
|
field,
|
||||||
userId,
|
userId,
|
||||||
authOptions,
|
authOptions,
|
||||||
recipientToken: token,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentMeta = await prisma.documentMeta.findFirst({
|
const documentMeta = await prisma.documentMeta.findFirst({
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
|
|||||||
let authLevel = match(actionAuthMethod)
|
let authLevel = match(actionAuthMethod)
|
||||||
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
|
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
|
||||||
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor 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('PASSWORD', () => i18n._(msg`Password Re-Authentication`))
|
||||||
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
|
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
|
||||||
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
|
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
|
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
|
||||||
|
|
||||||
export type QuotaFlags = {
|
export type QuotaFlags = {
|
||||||
isDocumentQuotaExceeded: boolean;
|
isDocumentQuotaExceeded: boolean;
|
||||||
@@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
|
|
||||||
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
|
|
||||||
*/
|
|
||||||
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
|
|
||||||
if (quota === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quota === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage >= quota;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A counter is "nearing" its quota once usage reaches the warning threshold
|
|
||||||
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
|
|
||||||
* exceeded are mutually exclusive per counter.
|
|
||||||
*/
|
|
||||||
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
|
|
||||||
if (quota === null || quota === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isQuotaExceeded(quota, usage)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
|
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
|
||||||
return {
|
return {
|
||||||
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
|
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const QUOTA_WARNING_THRESHOLD = 0.8;
|
import { getQuotaWarningCount } from '../../universal/quota-usage';
|
||||||
|
|
||||||
export type QuotaAlertKind = 'quota' | 'quotaNearing';
|
export type QuotaAlertKind = 'quota' | 'quotaNearing';
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
|
|||||||
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
|
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
|
||||||
// warning threshold equals the quota itself, the warning can never fire — the
|
// warning threshold equals the quota itself, the warning can never fire — the
|
||||||
// exhausting request is handled by the quota branch above.
|
// exhausting request is handled by the quota branch above.
|
||||||
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
const warningCount = getQuotaWarningCount(quota);
|
||||||
|
|
||||||
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
|
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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'));
|
|
||||||
};
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -56,13 +56,6 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
|||||||
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
|
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
|
||||||
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
|
'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',
|
|
||||||
// CSC / TSP signing events.
|
// CSC / TSP signing events.
|
||||||
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATED', // Service-scope OAuth complete; CSC credential persisted.
|
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATED', // Service-scope OAuth complete; CSC credential persisted.
|
||||||
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED', // Service-scope OAuth completed but TSP returned a blocking error (empty credential list / invalid cert / refused algorithm).
|
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED', // Service-scope OAuth completed but TSP returned a blocking error (empty credential list / invalid cert / refused algorithm).
|
||||||
@@ -747,59 +740,6 @@ 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.
|
* Event: Recipient's signing window expired.
|
||||||
*/
|
*/
|
||||||
@@ -925,12 +865,6 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
|||||||
ZDocumentAuditLogEventRecipientAddedSchema,
|
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||||
ZDocumentAuditLogEventExternal2FATokenIssuedSchema,
|
|
||||||
ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema,
|
|
||||||
ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema,
|
|
||||||
ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema,
|
|
||||||
ZDocumentAuditLogEventExternal2FATokenConsumedSchema,
|
|
||||||
ZDocumentAuditLogEventExternal2FATokenRevokedSchema,
|
|
||||||
ZDocumentAuditLogEventRecipientExpiredSchema,
|
ZDocumentAuditLogEventRecipientExpiredSchema,
|
||||||
ZDocumentAuditLogEventDocumentRecipientCscAuthenticatedSchema,
|
ZDocumentAuditLogEventDocumentRecipientCscAuthenticatedSchema,
|
||||||
ZDocumentAuditLogEventDocumentRecipientCscAuthenticationFailedSchema,
|
ZDocumentAuditLogEventDocumentRecipientCscAuthenticationFailedSchema,
|
||||||
|
|||||||
@@ -5,14 +5,7 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
|
|||||||
/**
|
/**
|
||||||
* All the available types of document authentication options for both access and action.
|
* All the available types of document authentication options for both access and action.
|
||||||
*/
|
*/
|
||||||
export const ZDocumentAuthTypesSchema = z.enum([
|
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'TWO_FACTOR_AUTH', 'PASSWORD', 'EXPLICIT_NONE']);
|
||||||
'ACCOUNT',
|
|
||||||
'PASSKEY',
|
|
||||||
'TWO_FACTOR_AUTH',
|
|
||||||
'EXTERNAL_TWO_FACTOR_AUTH',
|
|
||||||
'PASSWORD',
|
|
||||||
'EXPLICIT_NONE',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
||||||
|
|
||||||
@@ -41,10 +34,6 @@ const ZDocumentAuth2FASchema = z.object({
|
|||||||
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
|
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.
|
* All the document auth methods for both accessing and actioning.
|
||||||
*/
|
*/
|
||||||
@@ -53,7 +42,6 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
|||||||
ZDocumentAuthExplicitNoneSchema,
|
ZDocumentAuthExplicitNoneSchema,
|
||||||
ZDocumentAuthPasskeySchema,
|
ZDocumentAuthPasskeySchema,
|
||||||
ZDocumentAuth2FASchema,
|
ZDocumentAuth2FASchema,
|
||||||
ZDocumentAuthExternal2FASchema,
|
|
||||||
ZDocumentAuthPasswordSchema,
|
ZDocumentAuthPasswordSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -79,17 +67,10 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
|
|||||||
ZDocumentAuthAccountSchema,
|
ZDocumentAuthAccountSchema,
|
||||||
ZDocumentAuthPasskeySchema,
|
ZDocumentAuthPasskeySchema,
|
||||||
ZDocumentAuth2FASchema,
|
ZDocumentAuth2FASchema,
|
||||||
ZDocumentAuthExternal2FASchema,
|
|
||||||
ZDocumentAuthPasswordSchema,
|
ZDocumentAuthPasswordSchema,
|
||||||
]);
|
]);
|
||||||
export const ZDocumentActionAuthTypesSchema = z
|
export const ZDocumentActionAuthTypesSchema = z
|
||||||
.enum([
|
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD])
|
||||||
DocumentAuth.ACCOUNT,
|
|
||||||
DocumentAuth.PASSKEY,
|
|
||||||
DocumentAuth.TWO_FACTOR_AUTH,
|
|
||||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
|
||||||
DocumentAuth.PASSWORD,
|
|
||||||
])
|
|
||||||
.describe(
|
.describe(
|
||||||
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
|
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
|
||||||
);
|
);
|
||||||
@@ -116,7 +97,6 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
|
|||||||
ZDocumentAuthAccountSchema,
|
ZDocumentAuthAccountSchema,
|
||||||
ZDocumentAuthPasskeySchema,
|
ZDocumentAuthPasskeySchema,
|
||||||
ZDocumentAuth2FASchema,
|
ZDocumentAuth2FASchema,
|
||||||
ZDocumentAuthExternal2FASchema,
|
|
||||||
ZDocumentAuthPasswordSchema,
|
ZDocumentAuthPasswordSchema,
|
||||||
ZDocumentAuthExplicitNoneSchema,
|
ZDocumentAuthExplicitNoneSchema,
|
||||||
]);
|
]);
|
||||||
@@ -125,7 +105,6 @@ export const ZRecipientActionAuthTypesSchema = z
|
|||||||
DocumentAuth.ACCOUNT,
|
DocumentAuth.ACCOUNT,
|
||||||
DocumentAuth.PASSKEY,
|
DocumentAuth.PASSKEY,
|
||||||
DocumentAuth.TWO_FACTOR_AUTH,
|
DocumentAuth.TWO_FACTOR_AUTH,
|
||||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
|
||||||
DocumentAuth.PASSWORD,
|
DocumentAuth.PASSWORD,
|
||||||
DocumentAuth.EXPLICIT_NONE,
|
DocumentAuth.EXPLICIT_NONE,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { ZNameSchema } from './name';
|
||||||
|
|
||||||
|
describe('ZNameSchema', () => {
|
||||||
|
describe('valid names', () => {
|
||||||
|
it('accepts a normal name', () => {
|
||||||
|
expect(ZNameSchema.safeParse('Example User')).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: 'Example User',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts international characters', () => {
|
||||||
|
expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: 'Døcumensø Üser',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace', () => {
|
||||||
|
expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: 'Documenso User',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts names at the minimum length', () => {
|
||||||
|
expect(ZNameSchema.safeParse('DU')).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: 'DU',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts names at the maximum length', () => {
|
||||||
|
const name =
|
||||||
|
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do';
|
||||||
|
|
||||||
|
expect(name.length).toBe(100);
|
||||||
|
expect(ZNameSchema.safeParse(name)).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('length validation', () => {
|
||||||
|
it('rejects names shorter than 2 characters', () => {
|
||||||
|
expect(ZNameSchema.safeParse('D')).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: [{ message: 'Please enter a valid name.' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects names longer than 100 characters', () => {
|
||||||
|
const name =
|
||||||
|
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc';
|
||||||
|
|
||||||
|
expect(name.length).toBe(101);
|
||||||
|
expect(ZNameSchema.safeParse(name)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: [{ message: 'Name cannot be more than 100 characters.' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects whitespace-only input after trim', () => {
|
||||||
|
expect(ZNameSchema.safeParse(' ')).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('URL validation', () => {
|
||||||
|
it.each([
|
||||||
|
'https://example.com',
|
||||||
|
'http://example.com',
|
||||||
|
'HTTPS://EXAMPLE.COM',
|
||||||
|
'Northwind www.example.com',
|
||||||
|
'www.example.com',
|
||||||
|
])('rejects URLs in names: %s', (value) => {
|
||||||
|
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid character validation', () => {
|
||||||
|
it.each([
|
||||||
|
['NUL character', 'Acme\u0000Corp'],
|
||||||
|
['zero-width space', 'Acme\u200bCorp'],
|
||||||
|
['bidi override', 'Acme\u202eCorp'],
|
||||||
|
['byte order mark', 'Acme\ufeffCorp'],
|
||||||
|
['lone surrogate', 'Acme\ud800Corp'],
|
||||||
|
['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`],
|
||||||
|
['noncharacter', 'Acme\ufffeCorp'],
|
||||||
|
['private use character', 'Acme\ue000Corp'],
|
||||||
|
['Hangul filler', 'Acme\u3164Corp'],
|
||||||
|
['braille blank', 'Acme\u2800Corp'],
|
||||||
|
['combining grapheme joiner', 'Acme\u034fCorp'],
|
||||||
|
])('rejects names containing a %s', (_label, value) => {
|
||||||
|
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['fixed form', String.raw`Acme\u200bCorp`],
|
||||||
|
['uppercase U', String.raw`Acme\U200BCorp`],
|
||||||
|
['braced form', String.raw`Acme\u{200b}Corp`],
|
||||||
|
['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`],
|
||||||
|
['lone surrogate', String.raw`Acme\ud800Corp`],
|
||||||
|
])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => {
|
||||||
|
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['escape of a valid code point', String.raw`Acme\u0041Corp`],
|
||||||
|
['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`],
|
||||||
|
['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`],
|
||||||
|
['incomplete escape sequence', String.raw`Acme\u00 Corp`],
|
||||||
|
['unterminated braced escape', String.raw`Acme\u{200bCorp`],
|
||||||
|
['astral characters such as emoji', 'Acme 😀 Corp'],
|
||||||
|
['emoji with a variation selector', 'I ❤️ Docs'],
|
||||||
|
])('accepts %s', (_label, value) => {
|
||||||
|
expect(ZNameSchema.safeParse(value)).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const URL_PATTERN = /https?:\/\/|www\./i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters that render as empty/invisible or break text layout:
|
||||||
|
*
|
||||||
|
* - `\p{C}` - control, format, lone surrogate, private use and
|
||||||
|
* unassigned code points (NUL, zero-width spaces, bidi
|
||||||
|
* overrides, BOM, tag characters, noncharacters).
|
||||||
|
* - `\p{Zl}\p{Zp}` - line and paragraph separators.
|
||||||
|
* - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the
|
||||||
|
* character class because it is a combining mark, which
|
||||||
|
* lint rules reject inside classes.
|
||||||
|
* - remaining - letters that render as blank (Hangul fillers, braille blank).
|
||||||
|
*
|
||||||
|
* The `\p{...}` classes are maintained by the Unicode database, so newly
|
||||||
|
* assigned characters in these categories are covered automatically.
|
||||||
|
*/
|
||||||
|
const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u;
|
||||||
|
|
||||||
|
const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as
|
||||||
|
* text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still
|
||||||
|
* break rendering downstream if anything decodes them.
|
||||||
|
*/
|
||||||
|
const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi;
|
||||||
|
|
||||||
|
const hasInvalidEscapeSequence = (value: string) => {
|
||||||
|
for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) {
|
||||||
|
const codePoint = parseInt(fixedHex ?? bracedHex, 16);
|
||||||
|
|
||||||
|
if (codePoint > 0x10ffff) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the escape and run it through the same character policy as the
|
||||||
|
// unescaped check, so the two can never drift apart.
|
||||||
|
if (hasInvalidCharacter(String.fromCodePoint(codePoint))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasInvalidTextCharacters = (value: string) =>
|
||||||
|
hasInvalidCharacter(value) || hasInvalidEscapeSequence(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared name schema that disallows URLs to prevent phishing via email rendering,
|
||||||
|
* and invisible/control characters that render as empty or break the UI.
|
||||||
|
*/
|
||||||
|
export const ZNameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2, { message: 'Please enter a valid name.' })
|
||||||
|
.max(100, { message: 'Name cannot be more than 100 characters.' })
|
||||||
|
.refine((value) => !URL_PATTERN.test(value), {
|
||||||
|
message: 'Name cannot contain URLs.',
|
||||||
|
})
|
||||||
|
.refine((value) => !hasInvalidTextCharacters(value), {
|
||||||
|
message: 'Name contains invalid characters.',
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TName = z.infer<typeof ZNameSchema>;
|
||||||
@@ -6,14 +6,39 @@ import { z } from 'zod';
|
|||||||
*
|
*
|
||||||
* Example: "5m", "1h", "1d"
|
* Example: "5m", "1h", "1d"
|
||||||
*/
|
*/
|
||||||
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/);
|
export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
|
||||||
|
|
||||||
export const ZRateLimitArraySchema = z.array(
|
const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
|
||||||
z.object({
|
const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
|
||||||
window: ZRateLimitWindowSchema,
|
|
||||||
max: z.number().int().positive(),
|
export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, {
|
||||||
}),
|
message: RATE_LIMIT_WINDOW_ERROR_MESSAGE,
|
||||||
);
|
});
|
||||||
|
|
||||||
|
export const ZRateLimitArraySchema = z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
window: ZRateLimitWindowSchema,
|
||||||
|
max: z.number().int().positive(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.superRefine((entries, ctx) => {
|
||||||
|
const windows = new Set<string>();
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const window = entry.window.trim();
|
||||||
|
|
||||||
|
if (windows.has(window)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE,
|
||||||
|
path: [index, 'window'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
windows.add(window);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
|
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
|
||||||
|
|
||||||
@@ -49,8 +74,6 @@ export const ZClaimFlagsSchema = z.object({
|
|||||||
|
|
||||||
allowLegacyEnvelopes: z.boolean().optional(),
|
allowLegacyEnvelopes: z.boolean().optional(),
|
||||||
|
|
||||||
externalSigning2fa: z.boolean().optional(),
|
|
||||||
|
|
||||||
signingReminders: z.boolean().optional(),
|
signingReminders: z.boolean().optional(),
|
||||||
|
|
||||||
cscQesSigning: z.boolean().optional(),
|
cscQesSigning: z.boolean().optional(),
|
||||||
@@ -128,11 +151,6 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
|||||||
key: 'allowLegacyEnvelopes',
|
key: 'allowLegacyEnvelopes',
|
||||||
label: 'Allow Legacy Envelopes',
|
label: 'Allow Legacy Envelopes',
|
||||||
},
|
},
|
||||||
externalSigning2fa: {
|
|
||||||
key: 'externalSigning2fa',
|
|
||||||
label: 'External signing 2FA',
|
|
||||||
isEnterprise: true,
|
|
||||||
},
|
|
||||||
signingReminders: {
|
signingReminders: {
|
||||||
key: 'signingReminders',
|
key: 'signingReminders',
|
||||||
label: 'Signing reminders',
|
label: 'Signing reminders',
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getQuotaUsagePercent,
|
||||||
|
getQuotaWarningCount,
|
||||||
|
isQuotaExceeded,
|
||||||
|
isQuotaNearing,
|
||||||
|
normalizeCapacityLimit,
|
||||||
|
} from './quota-usage';
|
||||||
|
|
||||||
|
describe('isQuotaExceeded', () => {
|
||||||
|
it('treats null quota as unlimited (never exceeded)', () => {
|
||||||
|
expect(isQuotaExceeded(null, 0)).toBe(false);
|
||||||
|
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a zero quota as blocked (always exceeded)', () => {
|
||||||
|
expect(isQuotaExceeded(0, 0)).toBe(true);
|
||||||
|
expect(isQuotaExceeded(0, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is exceeded once usage reaches the quota (>= boundary)', () => {
|
||||||
|
expect(isQuotaExceeded(10, 9)).toBe(false);
|
||||||
|
expect(isQuotaExceeded(10, 10)).toBe(true);
|
||||||
|
expect(isQuotaExceeded(10, 11)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getQuotaWarningCount', () => {
|
||||||
|
it('rounds the 80% threshold up', () => {
|
||||||
|
expect(getQuotaWarningCount(10)).toBe(8);
|
||||||
|
expect(getQuotaWarningCount(100)).toBe(80);
|
||||||
|
// 5 * 0.8 = 4 exactly.
|
||||||
|
expect(getQuotaWarningCount(5)).toBe(4);
|
||||||
|
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
|
||||||
|
expect(getQuotaWarningCount(3)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isQuotaNearing', () => {
|
||||||
|
it('is never nearing for unlimited or blocked quotas', () => {
|
||||||
|
expect(isQuotaNearing(null, 5)).toBe(false);
|
||||||
|
expect(isQuotaNearing(0, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is nearing from the warning threshold up to (but not including) the quota', () => {
|
||||||
|
expect(isQuotaNearing(10, 7)).toBe(false);
|
||||||
|
expect(isQuotaNearing(10, 8)).toBe(true);
|
||||||
|
expect(isQuotaNearing(10, 9)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
|
||||||
|
expect(isQuotaNearing(10, 10)).toBe(false);
|
||||||
|
expect(isQuotaNearing(10, 11)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can never fire for tiny quotas where the warning count equals the quota', () => {
|
||||||
|
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
|
||||||
|
expect(isQuotaNearing(3, 2)).toBe(false);
|
||||||
|
expect(isQuotaNearing(3, 3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees with the warning-count helper at the boundary', () => {
|
||||||
|
const quota = 250;
|
||||||
|
const warningCount = getQuotaWarningCount(quota);
|
||||||
|
|
||||||
|
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
|
||||||
|
expect(isQuotaNearing(quota, warningCount)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getQuotaUsagePercent', () => {
|
||||||
|
it('returns 0 for unlimited or non-positive quotas', () => {
|
||||||
|
expect(getQuotaUsagePercent(5, null)).toBe(0);
|
||||||
|
expect(getQuotaUsagePercent(5, 0)).toBe(0);
|
||||||
|
expect(getQuotaUsagePercent(5, -10)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds the percentage to the nearest integer', () => {
|
||||||
|
expect(getQuotaUsagePercent(1, 3)).toBe(33);
|
||||||
|
expect(getQuotaUsagePercent(2, 3)).toBe(67);
|
||||||
|
expect(getQuotaUsagePercent(50, 100)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the percentage to 100 when usage exceeds the quota', () => {
|
||||||
|
expect(getQuotaUsagePercent(150, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeCapacityLimit', () => {
|
||||||
|
it('maps 0 (unlimited for capacity limits) to null', () => {
|
||||||
|
expect(normalizeCapacityLimit(0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes positive limits through unchanged', () => {
|
||||||
|
expect(normalizeCapacityLimit(1)).toBe(1);
|
||||||
|
expect(normalizeCapacityLimit(25)).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export const QUOTA_WARNING_THRESHOLD = 0.8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
|
||||||
|
*/
|
||||||
|
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
|
||||||
|
if (quota === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quota === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage >= quota;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The usage count at which a positive quota starts "nearing" (80% rounded up).
|
||||||
|
* The single source for the warning threshold math so the UI panel, quota flags,
|
||||||
|
* and the per-request alert path can't drift apart.
|
||||||
|
*/
|
||||||
|
export const getQuotaWarningCount = (quota: number): number => {
|
||||||
|
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
|
||||||
|
*/
|
||||||
|
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
|
||||||
|
if (quota === null || quota === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQuotaExceeded(quota, usage)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage >= getQuotaWarningCount(quota);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
|
||||||
|
if (quota === null || quota <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(100, Math.round((usage / quota) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Member/team capacity limits use `0` for unlimited. */
|
||||||
|
export const normalizeCapacityLimit = (limit: number): number | null => {
|
||||||
|
if (limit === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return limit;
|
||||||
|
};
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import { env } from '@documenso/lib/utils/env';
|
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
||||||
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
|
|
||||||
import { DocumentDataType } from '@prisma/client';
|
|
||||||
import { base64 } from '@scure/base';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|
||||||
import { AppError } from '../../errors/app-error';
|
import { AppError } from '../../errors/app-error';
|
||||||
|
|
||||||
type File = {
|
type File = {
|
||||||
@@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads a file to the appropriate storage location.
|
|
||||||
*/
|
|
||||||
export const putFile = async (file: File, options?: PutFileOptions) => {
|
|
||||||
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
|
|
||||||
|
|
||||||
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
|
|
||||||
.with('s3', async () => putFileInObjectStorage(file, {}, options))
|
|
||||||
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
|
|
||||||
.otherwise(async () => putFileInDatabase(file));
|
|
||||||
};
|
|
||||||
|
|
||||||
const putFileInDatabase = async (file: File) => {
|
|
||||||
const contents = await file.arrayBuffer();
|
|
||||||
|
|
||||||
const binaryData = new Uint8Array(contents);
|
|
||||||
|
|
||||||
const asciiData = base64.encode(binaryData);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: DocumentDataType.BYTES_64,
|
|
||||||
data: asciiData,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
|
|
||||||
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...buildUploadAuthHeaders(options),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fileName: file.name,
|
|
||||||
contentType: file.type,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!getPresignedUrlResponse.ok) {
|
|
||||||
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
|
|
||||||
|
|
||||||
const body = await file.arrayBuffer();
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
...extraHeaders,
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: DocumentDataType.S3_PATH,
|
|
||||||
data: key,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -625,48 +625,6 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
|||||||
user: message,
|
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 };
|
|
||||||
})
|
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED }, () => ({
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED }, () => ({
|
||||||
anonymous: msg`Recipient authenticated with the signing provider`,
|
anonymous: msg`Recipient authenticated with the signing provider`,
|
||||||
you: msg`You authenticated with the signing provider`,
|
you: msg`You authenticated with the signing provider`,
|
||||||
|
|||||||
@@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitise an uploaded branding logo. Re-encoding through `sharp`
|
||||||
|
* proves the bytes are a real raster image and strips any embedded payloads.
|
||||||
|
* Throws if the input cannot be parsed as an image.
|
||||||
|
*/
|
||||||
|
export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise<Buffer> => {
|
||||||
|
return await sharp(input)
|
||||||
|
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.png({ quality: 80 })
|
||||||
|
.toBuffer();
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -486,9 +486,6 @@ model Envelope {
|
|||||||
|
|
||||||
envelopeAttachments EnvelopeAttachment[]
|
envelopeAttachments EnvelopeAttachment[]
|
||||||
|
|
||||||
signingTwoFactorTokens SigningTwoFactorToken[]
|
|
||||||
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
|
|
||||||
|
|
||||||
@@index([type])
|
@@index([type])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -657,8 +654,6 @@ model Recipient {
|
|||||||
fields Field[]
|
fields Field[]
|
||||||
signatures Signature[]
|
signatures Signature[]
|
||||||
|
|
||||||
signingTwoFactorTokens SigningTwoFactorToken[]
|
|
||||||
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
|
|
||||||
cscCredential CscCredential?
|
cscCredential CscCredential?
|
||||||
cscSession CscSession?
|
cscSession CscSession?
|
||||||
|
|
||||||
@@ -1267,58 +1262,6 @@ model Counter {
|
|||||||
value Int
|
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 {
|
model RateLimit {
|
||||||
key String
|
key String
|
||||||
action String
|
action String
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
|
|
||||||
|
|
||||||
export const ZCreateAdminOrganisationRequestSchema = z.object({
|
export const ZCreateAdminOrganisationRequestSchema = z.object({
|
||||||
ownerUserId: z.number(),
|
ownerUserId: z.number(),
|
||||||
data: z.object({
|
data: z.object({
|
||||||
name: ZOrganisationNameSchema,
|
name: ZNameSchema,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
|
import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZCreateSubscriptionClaimRequestSchema = z.object({
|
export const ZCreateSubscriptionClaimRequestSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: ZNameSchema,
|
||||||
teamCount: z.number().int().min(0),
|
teamCount: z.number().int().min(0),
|
||||||
memberCount: z.number().int().min(0),
|
memberCount: z.number().int().min(0),
|
||||||
envelopeItemCount: z.number().int().min(1),
|
envelopeItemCount: z.number().int().min(1),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZCreateUserRequestSchema = z.object({
|
export const ZCreateUserRequestSchema = z.object({
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
|
import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZCreateEmailTransportRequestSchema = z.object({
|
export const ZCreateEmailTransportRequestSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: ZNameSchema,
|
||||||
fromName: z.string().min(1),
|
fromName: ZNameSchema,
|
||||||
fromAddress: z.string().email(),
|
fromAddress: z.string().email(),
|
||||||
config: ZEmailTransportConfigSchema,
|
config: ZEmailTransportConfigSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ZSmtpApiConfigSchema,
|
ZSmtpApiConfigSchema,
|
||||||
ZSmtpAuthConfigSchema,
|
ZSmtpAuthConfigSchema,
|
||||||
} from '@documenso/lib/server-only/email/email-transport-config';
|
} from '@documenso/lib/server-only/email/email-transport-config';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Reuses the canonical transport config schemas, but relaxes the secret field so
|
// Reuses the canonical transport config schemas, but relaxes the secret field so
|
||||||
@@ -21,8 +22,8 @@ const ZUpdateConfigSchema = z.discriminatedUnion('type', [
|
|||||||
export const ZUpdateEmailTransportRequestSchema = z.object({
|
export const ZUpdateEmailTransportRequestSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
data: z.object({
|
data: z.object({
|
||||||
name: z.string().min(1),
|
name: ZNameSchema,
|
||||||
fromName: z.string().min(1),
|
fromName: ZNameSchema,
|
||||||
fromAddress: z.string().email(),
|
fromAddress: z.string().email(),
|
||||||
config: ZUpdateConfigSchema,
|
config: ZUpdateConfigSchema,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure
|
|||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
ownerUserId: true,
|
ownerUserId: true,
|
||||||
|
organisationGlobalSettings: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
teamEmail: true,
|
teamEmail: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
|
import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
|
||||||
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
|
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
|
||||||
|
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
|
||||||
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
|
import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
|
||||||
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
|
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
|
||||||
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
|
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
|
||||||
@@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({
|
|||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
ownerUserId: true,
|
ownerUserId: true,
|
||||||
|
}).extend({
|
||||||
|
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
|
||||||
}),
|
}),
|
||||||
teamEmail: TeamEmailSchema.nullable(),
|
teamEmail: TeamEmailSchema.nullable(),
|
||||||
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
|
teamGlobalSettings: TeamGlobalSettingsSchema.nullable(),
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
|
|
||||||
import { ZTeamUrlSchema } from '../team-router/schema';
|
import { ZTeamUrlSchema } from '../team-router/schema';
|
||||||
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
|
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
|
||||||
|
|
||||||
export const ZUpdateAdminOrganisationRequestSchema = z.object({
|
export const ZUpdateAdminOrganisationRequestSchema = z.object({
|
||||||
organisationId: z.string(),
|
organisationId: z.string(),
|
||||||
data: z.object({
|
data: z.object({
|
||||||
name: ZOrganisationNameSchema.optional(),
|
name: ZNameSchema.optional(),
|
||||||
url: ZTeamUrlSchema.optional(),
|
url: ZTeamUrlSchema.optional(),
|
||||||
claims: ZCreateSubscriptionClaimRequestSchema.pick({
|
claims: ZCreateSubscriptionClaimRequestSchema.pick({
|
||||||
teamCount: true,
|
teamCount: true,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZUpdateUserRequestSchema = z.object({
|
export const ZUpdateUserRequestSchema = z.object({
|
||||||
id: z.number().min(1),
|
id: z.number().min(1),
|
||||||
name: z.string().nullish(),
|
name: ZNameSchema.nullish(),
|
||||||
email: zEmail().optional(),
|
email: zEmail().optional(),
|
||||||
roles: z.array(z.nativeEnum(Role)).optional(),
|
roles: z.array(z.nativeEnum(Role)).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZCreateApiTokenRequestSchema = z.object({
|
export const ZCreateApiTokenRequestSchema = z.object({
|
||||||
teamId: z.number(),
|
teamId: z.number(),
|
||||||
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
|
tokenName: ZNameSchema,
|
||||||
expirationDate: z.string().nullable(),
|
expirationDate: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
|
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZCreatePasskeyRequestSchema = z.object({
|
export const ZCreatePasskeyRequestSchema = z.object({
|
||||||
passkeyName: z.string().trim().min(1),
|
passkeyName: ZNameSchema,
|
||||||
verificationResponse: ZRegistrationResponseJSONSchema,
|
verificationResponse: ZRegistrationResponseJSONSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZUpdatePasskeyRequestSchema = z.object({
|
export const ZUpdatePasskeyRequestSchema = z.object({
|
||||||
passkeyId: z.string().trim().min(1),
|
passkeyId: z.string().trim().min(1),
|
||||||
name: z.string().trim().min(1),
|
name: ZNameSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZUpdatePasskeyResponseSchema = z.void();
|
export const ZUpdatePasskeyResponseSchema = z.void();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { zEmail } from '@documenso/lib/utils/zod';
|
import { zEmail } from '@documenso/lib/utils/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZCreateOrganisationEmailRequestSchema = z.object({
|
export const ZCreateOrganisationEmailRequestSchema = z.object({
|
||||||
emailDomainId: z.string(),
|
emailDomainId: z.string(),
|
||||||
emailName: z.string().min(1).max(100),
|
emailName: ZNameSchema,
|
||||||
email: zEmail().toLowerCase(),
|
email: zEmail().toLowerCase(),
|
||||||
|
|
||||||
// This does not need to be validated to be part of the domain.
|
// This does not need to be validated to be part of the domain.
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ import { saveAsTemplateRoute } from './save-as-template';
|
|||||||
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
|
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
|
||||||
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
|
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
|
||||||
import { signEnvelopeFieldRoute } from './sign-envelope-field';
|
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 { signingStatusEnvelopeRoute } from './signing-status-envelope';
|
||||||
import { updateEnvelopeRoute } from './update-envelope';
|
import { updateEnvelopeRoute } from './update-envelope';
|
||||||
import { updateEnvelopeItemsRoute } from './update-envelope-items';
|
import { updateEnvelopeItemsRoute } from './update-envelope-items';
|
||||||
@@ -114,10 +111,5 @@ export const envelopeRouter = router({
|
|||||||
saveAsTemplate: saveAsTemplateRoute,
|
saveAsTemplate: saveAsTemplateRoute,
|
||||||
distribute: distributeEnvelopeRoute,
|
distribute: distributeEnvelopeRoute,
|
||||||
redistribute: redistributeEnvelopeRoute,
|
redistribute: redistributeEnvelopeRoute,
|
||||||
signing2fa: {
|
|
||||||
issue: issueSigningTwoFactorTokenRoute,
|
|
||||||
verify: verifySigningTwoFactorTokenRoute,
|
|
||||||
getStatus: getSigningTwoFactorStatusRoute,
|
|
||||||
},
|
|
||||||
signingStatus: signingStatusEnvelopeRoute,
|
signingStatus: signingStatusEnvelopeRoute,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -176,7 +176,6 @@ export const signEnvelopeFieldRoute = procedure
|
|||||||
field,
|
field,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
authOptions,
|
authOptions,
|
||||||
recipientToken: token,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
>;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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
@@ -1,35 +0,0 @@
|
|||||||
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
|
|
||||||
>;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
>;
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
|
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
|
||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||||
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||||
@@ -42,7 +43,7 @@ const ZFolderParentIdSchema = z
|
|||||||
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
|
.describe('The folder ID to place this folder within. Leave empty to place folder at the root level.');
|
||||||
|
|
||||||
export const ZCreateFolderRequestSchema = z.object({
|
export const ZCreateFolderRequestSchema = z.object({
|
||||||
name: z.string(),
|
name: ZNameSchema,
|
||||||
parentId: ZFolderParentIdSchema.optional(),
|
parentId: ZFolderParentIdSchema.optional(),
|
||||||
type: ZFolderTypeSchema.optional(),
|
type: ZFolderTypeSchema.optional(),
|
||||||
});
|
});
|
||||||
@@ -52,7 +53,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema;
|
|||||||
export const ZUpdateFolderRequestSchema = z.object({
|
export const ZUpdateFolderRequestSchema = z.object({
|
||||||
folderId: z.string().describe('The ID of the folder to update'),
|
folderId: z.string().describe('The ID of the folder to update'),
|
||||||
data: z.object({
|
data: z.object({
|
||||||
name: z.string().optional().describe('The name of the folder'),
|
name: ZNameSchema.optional().describe('The name of the folder'),
|
||||||
parentId: ZFolderParentIdSchema.optional().nullable(),
|
parentId: ZFolderParentIdSchema.optional().nullable(),
|
||||||
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
|
visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'),
|
||||||
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
|
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
import { OrganisationMemberRole } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ import { z } from 'zod';
|
|||||||
export const ZCreateOrganisationGroupRequestSchema = z.object({
|
export const ZCreateOrganisationGroupRequestSchema = z.object({
|
||||||
organisationId: z.string(),
|
organisationId: z.string(),
|
||||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||||
name: z.string().max(100),
|
name: ZNameSchema,
|
||||||
memberIds: z.array(z.string()),
|
memberIds: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// export const createOrganisationMeta: TrpcOpenApiMeta = {
|
// export const createOrganisationMeta: TrpcOpenApiMeta = {
|
||||||
@@ -10,13 +11,8 @@ import { z } from 'zod';
|
|||||||
// },
|
// },
|
||||||
// };
|
// };
|
||||||
|
|
||||||
export const ZOrganisationNameSchema = z
|
|
||||||
.string()
|
|
||||||
.min(3, { message: 'Minimum 3 characters' })
|
|
||||||
.max(50, { message: 'Maximum 50 characters' });
|
|
||||||
|
|
||||||
export const ZCreateOrganisationRequestSchema = z.object({
|
export const ZCreateOrganisationRequestSchema = z.object({
|
||||||
name: ZOrganisationNameSchema,
|
name: ZNameSchema,
|
||||||
priceId: z.string().optional(),
|
priceId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { getOrganisationsRoute } from './get-organisations';
|
|||||||
import { leaveOrganisationRoute } from './leave-organisation';
|
import { leaveOrganisationRoute } from './leave-organisation';
|
||||||
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
|
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
|
||||||
import { updateOrganisationRoute } from './update-organisation';
|
import { updateOrganisationRoute } from './update-organisation';
|
||||||
|
import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo';
|
||||||
import { updateOrganisationGroupRoute } from './update-organisation-group';
|
import { updateOrganisationGroupRoute } from './update-organisation-group';
|
||||||
import { updateOrganisationMemberRoute } from './update-organisation-members';
|
import { updateOrganisationMemberRoute } from './update-organisation-members';
|
||||||
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
|
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
|
||||||
@@ -55,6 +56,7 @@ export const organisationRouter = router({
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
update: updateOrganisationSettingsRoute,
|
update: updateOrganisationSettingsRoute,
|
||||||
|
updateBrandingLogo: updateOrganisationBrandingLogoRoute,
|
||||||
},
|
},
|
||||||
internal: {
|
internal: {
|
||||||
getOrganisationSession: getOrganisationSessionRoute,
|
getOrganisationSession: getOrganisationSessionRoute,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo';
|
||||||
|
import { getOrganisationClaim } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
|
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { authenticatedProcedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZUpdateOrganisationBrandingLogoRequestSchema,
|
||||||
|
ZUpdateOrganisationBrandingLogoResponseSchema,
|
||||||
|
} from './update-organisation-branding-logo.types';
|
||||||
|
|
||||||
|
export const updateOrganisationBrandingLogoRoute = authenticatedProcedure
|
||||||
|
.input(ZUpdateOrganisationBrandingLogoRequestSchema)
|
||||||
|
.output(ZUpdateOrganisationBrandingLogoResponseSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { user } = ctx;
|
||||||
|
const { payload, brandingLogo } = input;
|
||||||
|
const { organisationId } = payload;
|
||||||
|
|
||||||
|
ctx.logger.info({
|
||||||
|
input: {
|
||||||
|
organisationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const organisation = await prisma.organisation.findFirst({
|
||||||
|
where: buildOrganisationWhereQuery({
|
||||||
|
organisationId,
|
||||||
|
userId: user.id,
|
||||||
|
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'You do not have permission to update this organisation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting a logo requires the custom-branding entitlement; clearing it is
|
||||||
|
// always allowed so a downgraded organisation can still remove its logo.
|
||||||
|
if (brandingLogo && IS_BILLING_ENABLED()) {
|
||||||
|
const claim = await getOrganisationClaim({ organisationId });
|
||||||
|
|
||||||
|
if (claim.flags?.allowCustomBranding !== true) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'Your plan does not allow custom branding.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : '';
|
||||||
|
|
||||||
|
await prisma.organisation.update({
|
||||||
|
where: {
|
||||||
|
id: organisation.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
organisationGlobalSettings: {
|
||||||
|
update: {
|
||||||
|
brandingLogo: brandingLogoValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { zfd } from 'zod-form-data';
|
||||||
|
|
||||||
|
import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data';
|
||||||
|
|
||||||
|
export const ZUpdateOrganisationBrandingLogoRequestSchema = zodFormData({
|
||||||
|
payload: zfd.json(
|
||||||
|
z.object({
|
||||||
|
organisationId: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
brandingLogo: zfdBrandingImageFile().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZUpdateOrganisationBrandingLogoResponseSchema = z.void();
|
||||||
|
|
||||||
|
export type TUpdateOrganisationBrandingLogoRequest = z.infer<typeof ZUpdateOrganisationBrandingLogoRequestSchema>;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
import { OrganisationMemberRole } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const ZUpdateOrganisationGroupRequestSchema = z.object({
|
export const ZUpdateOrganisationGroupRequestSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().nullable().optional(),
|
name: ZNameSchema.nullable().optional(),
|
||||||
organisationRole: z.nativeEnum(OrganisationMemberRole).optional(),
|
organisationRole: z.nativeEnum(OrganisationMemberRole).optional(),
|
||||||
memberIds: z.array(z.string()).optional(),
|
memberIds: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
brandingLogo,
|
|
||||||
brandingUrl,
|
brandingUrl,
|
||||||
brandingCompanyDetails,
|
brandingCompanyDetails,
|
||||||
brandingColors,
|
brandingColors,
|
||||||
@@ -174,7 +173,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
brandingLogo,
|
|
||||||
brandingUrl,
|
brandingUrl,
|
||||||
brandingCompanyDetails,
|
brandingCompanyDetails,
|
||||||
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
|||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
brandingEnabled: z.boolean().optional(),
|
brandingEnabled: z.boolean().optional(),
|
||||||
brandingLogo: z.string().optional(),
|
|
||||||
brandingUrl: z.string().optional(),
|
brandingUrl: z.string().optional(),
|
||||||
brandingCompanyDetails: z.string().optional(),
|
brandingCompanyDetails: z.string().optional(),
|
||||||
brandingColors: ZCssVarsSchema.nullish(),
|
brandingColors: ZCssVarsSchema.nullish(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
import { ZNameSchema } from '@documenso/lib/types/name';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZFindUserSecurityAuditLogsSchema = z.object({
|
export const ZFindUserSecurityAuditLogsSchema = z.object({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user