Compare commits

..

6 Commits

Author SHA1 Message Date
Tanushree Ahir 400b6a24f1 feat: enable real-time validation for email and password fields (#2204)
## Description

This MR improves the real-time validation experience on the signup page.
Currently, both the email and password fields validate only on blur,
causing users to continue seeing error messages even after correcting
their input until they click outside the field.

This update switches the React Hook Form configuration from mode:
'onBlur' to mode: 'onChange', ensuring validations update immediately as
the user types.
This results in a smoother and less confusing signup experience.

## Related Issue

Fixes #2200

## Changes Made

- Updated React Hook Form configuration in the signup form from:
- mode: 'onBlur' ➜ mode: 'onChange'
- Ensures real-time validation for email and password fields
- No schema or logic changes — only validation trigger updated


## Testing Performed

- Manually tested the signup page for:
- Real-time password rule updates
- Real-time email validation feedback
- Error disappearance immediately after correcting input

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [x] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [x] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes

This is a small UX-focused improvement; the validation schema remains
unchanged.

Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2026-07-10 13:41:31 +03:00
roshboi 1b1e3d197b Add "Apply Your License Key" self-hosting guide (#3077)
Adds a dedicated self-hosting page documenting how to apply an
Enterprise license key, and cross-links it from the related docs.

New page: self-hosting/configuration/license.mdx — "Apply Your License
Key"
Set NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY (Docker Compose / docker run /
.env tabs)
2026-07-08 16:11:49 +10:00
Lucas Smith a276e18e1f chore: upgrade deps (#3065) 2026-07-08 16:11:00 +10:00
Catalin Pit 50f272be87 fix: admin organisation limits and usage UI (#3014) 2026-07-02 16:50:11 +10:00
Catalin Pit a55e6d9484 fix: block invisible & control characters and URLs in names (#2978) 2026-07-02 16:20:56 +10:00
David Nguyen d35d13db23 fix: remove presigned branding upload (#3053) 2026-07-02 15:51:19 +10:00
110 changed files with 3930 additions and 3540 deletions
@@ -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 -1
View File
@@ -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';
+2 -2
View File
@@ -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>
); );
}; };
@@ -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>
@@ -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>
);
};
@@ -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`))
+4 -4
View File
@@ -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 -28
View File
@@ -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),
-1
View File
@@ -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.
+2048 -1264
View File
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 -1
View File
@@ -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';
-15
View File
@@ -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',
+10
View File
@@ -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'];
-13
View File
@@ -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,
});
};
-66
View File
@@ -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,
+2 -23
View File
@@ -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,
]) ])
+145
View File
@@ -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,
});
});
});
});
+68
View File
@@ -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>;
+32 -14
View File
@@ -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);
});
});
+57
View File
@@ -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 -71
View File
@@ -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,
};
};
-42
View File
@@ -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`,
+12
View File
@@ -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;
-57
View File
@@ -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,
});
});
@@ -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;
});
@@ -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,
};
});
@@ -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
>;
+3 -2
View File
@@ -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