Compare commits

..

3 Commits

Author SHA1 Message Date
Lucas Smith c6b797a6ce fix: increase global API rate limits to 1000/min 2026-07-08 17:07:31 +10: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
21 changed files with 3290 additions and 2313 deletions
@@ -11,9 +11,14 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
**Limit:** 100 requests per minute per IP address
**Limit:** 1000 requests per minute per IP address
**Response:** 429 Too Many Requests
<Callout type="info">
This is the global per-IP ceiling. Your organisation may have its own rate limits configured below
this value, in which case you can be rate-limited before reaching the global limit.
</Callout>
### Rate Limit Response
```json
@@ -472,7 +472,7 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
<code>distributeDocument: true</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute)
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
</Step>
</Steps>
@@ -638,8 +638,8 @@ done
</Tabs>
<Callout type="info">
The API allows 100 requests per minute. For large batches, implement rate limiting with delays
between requests to avoid hitting limits.
The API allows 1000 requests per minute (your organisation may have its own lower limit). For large
batches, implement rate limiting with delays between requests to avoid hitting limits.
</Callout>
---
@@ -483,7 +483,7 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying:
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -76,6 +76,8 @@ The Enterprise Edition is required when you:
4. Restart your Documenso instance
5. Verify the license is active in the **Admin Panel** under the **Stats** section
See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed.
</Accordion>
</Accordions>
@@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options.
1. Sign the Enterprise license agreement
2. Receive license key and access credentials
3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud
4. 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>
@@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options.
## Related
- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation
- [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license
- [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ
- [Support](/docs/policies/support) - Support channels and response times
+6 -1
View File
@@ -41,12 +41,17 @@ When a limit is reached, requests return a `429 Too Many Requests` response with
| Action | Limit | Window |
| --- | --- | --- |
| API requests (v1 and v2) | 100 requests | 1 minute |
| API requests (v1 and v2) | 1000 requests | 1 minute |
| File uploads | 20 requests | 1 minute |
| AI features | 3 requests | 1 minute |
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
<Callout type="info">
The API request limit above is the global per-IP ceiling. Individual organisations also have their
own rate limits, which may be configured below this value.
</Callout>
<Callout type="info">
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
[sales](https://documen.so/sales) for details.
@@ -443,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
## Enterprise Features
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
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 |
| ------------------------------------ | ------------------------------------------------ |
| `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_WEBHOOK_SECRET` | Stripe webhook secret |
| `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",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
@@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL`
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
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>
@@ -141,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
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,20 +1,48 @@
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { match, P } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
export type AuditLogDataTableProps = {
logs: TDocumentAuditLog[];
};
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
/**
* Get the color indicator for the audit log type
*/
const getAuditLogIndicatorColor = (type: string) =>
match(type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => 'bg-green-500')
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => 'bg-red-500')
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => 'bg-orange-500')
.with(
P.union(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED),
() => 'bg-blue-500',
)
.otherwise(() => 'bg-muted');
/**
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
*/
const formatUserAgent = (userAgent: string, userAgentInfo: UAParser.IResult) => {
const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => {
if (!userAgent) {
return msg`N/A`;
}
const browser = userAgentInfo.browser.name;
const version = userAgentInfo.browser.version;
const os = userAgentInfo.os.name;
@@ -35,45 +63,74 @@ export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
const parser = new UAParser();
return (
<div className="divide-y divide-border">
<div className="space-y-4">
{logs.map((log, index) => {
parser.setUA(log.userAgent || '');
const formattedAction = formatDocumentAuditLogAction(i18n, log);
const userAgentInfo = parser.getResult();
const createdAt = DateTime.fromJSDate(log.createdAt).setLocale(APP_I18N_OPTIONS.defaultLocale);
const metaSegments = [
log.email,
log.ipAddress,
log.userAgent ? _(formatUserAgent(log.userAgent, userAgentInfo)) : null,
].filter((segment): segment is string => Boolean(segment));
return (
<div
<Card
key={index}
className="flex gap-5 py-2.5"
// Add top margin for the first card to ensure it's not cut off from the 2nd page onwards
className={`border shadow-sm ${index > 0 ? 'print:mt-8' : ''}`}
style={{
pageBreakInside: 'avoid',
breakInside: 'avoid',
}}
>
<div className="w-[5.5rem] shrink-0">
<div className="text-foreground text-xs">{createdAt.toFormat('yyyy-MM-dd')}</div>
<CardContent className="p-4">
{/* Header Section with indicator, event type, and timestamp */}
<div className="mb-3 flex items-start justify-between">
<div className="flex items-baseline gap-3">
<div className={cn(`h-2 w-2 rounded-full`, getAuditLogIndicatorColor(log.type))} />
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">{createdAt.toFormat('hh:mm:ss a')}</div>
</div>
<div>
<div className="font-medium text-muted-foreground text-sm uppercase tracking-wide print:text-[8pt]">
{log.type.replace(/_/g, ' ')}
</div>
<div className="min-w-0 flex-1">
<div className="text-pretty text-foreground text-sm print:text-xs">{formattedAction.description}</div>
{metaSegments.length > 0 && (
<div className="mt-1 break-words text-muted-foreground text-xs print:text-[0.6875rem]">
{metaSegments.join(' · ')}
<div className="font-medium text-foreground text-sm print:text-[8pt]">
{formattedAction.description}
</div>
</div>
</div>
)}
</div>
</div>
<div className="text-muted-foreground text-sm print:text-[8pt]">
{DateTime.fromJSDate(log.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toLocaleString(dateFormat)}
</div>
</div>
<hr className="my-4" />
{/* Details Section - Two column layout */}
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-xs print:text-[6pt]">
<div>
<div className="font-medium text-muted-foreground/70 uppercase tracking-wide">{_(msg`User`)}</div>
<div className="mt-1 font-mono text-foreground">{log.email || 'N/A'}</div>
</div>
<div className="text-right">
<div className="font-medium text-muted-foreground/70 uppercase tracking-wide">
{_(msg`IP Address`)}
</div>
<div className="mt-1 font-mono text-foreground">{log.ipAddress || 'N/A'}</div>
</div>
<div className="col-span-2">
<div className="font-medium text-muted-foreground/70 uppercase tracking-wide">
{_(msg`User Agent`)}
</div>
<div className="mt-1 text-foreground">{_(formatUserAgent(log.userAgent, userAgentInfo))}</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
@@ -7,6 +7,7 @@ import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { getTranslations } from '@documenso/lib/utils/i18n';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { EnvelopeType } from '@prisma/client';
@@ -105,86 +106,82 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<header>
<h1 className="font-semibold text-lg tracking-tight">{_(msg`Audit Log`)}</h1>
<div className="mb-6 border-b pb-4">
<h1 className="font-semibold text-xl">{_(msg`Audit Log`)}</h1>
</div>
<p className="mt-1 text-pretty text-muted-foreground text-sm">{document.title}</p>
</header>
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
<p>
<span className="font-medium">{_(msg`Envelope ID`)}</span>
<dl className="mt-6 grid grid-cols-2 gap-x-8 gap-y-5">
<div>
<dt className="font-medium text-muted-foreground text-xs">{_(msg`Envelope ID`)}</dt>
<span className="mt-1 block break-words">{document.envelopeId}</span>
</p>
<dd className="mt-1 break-all text-foreground text-sm print:text-xs">{document.envelopeId}</dd>
</div>
<p>
<span className="font-medium">{_(msg`Enclosed Document`)}</span>
<div>
<dt className="font-medium text-muted-foreground text-xs">{_(msg`Owner`)}</dt>
<span className="mt-1 block break-words">{document.title}</span>
</p>
<dd className="mt-1 break-words text-foreground text-sm print:text-xs">
{document.user.name} ({document.user.email})
</dd>
</div>
<p>
<span className="font-medium">{_(msg`Status`)}</span>
<div>
<dt className="font-medium text-muted-foreground text-xs">{_(msg`Status`)}</dt>
<span className="mt-1 block">
{_(document.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[document.status].description).toUpperCase()}
</span>
</p>
<dd className="mt-1 text-foreground text-sm print:text-xs">
{_(document.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[document.status].description)}
</dd>
</div>
<p>
<span className="font-medium">{_(msg`Owner`)}</span>
<div>
<dt className="font-medium text-muted-foreground text-xs">{_(msg`Time Zone`)}</dt>
<span className="mt-1 block break-words">
{document.user.name} ({document.user.email})
</span>
</p>
<dd className="mt-1 break-words text-foreground text-sm print:text-xs">
{document.documentMeta?.timezone ?? 'N/A'}
</dd>
</div>
<p>
<span className="font-medium">{_(msg`Created At`)}</span>
<div>
<dt className="font-medium text-muted-foreground text-xs">{_(msg`Created At`)}</dt>
<span className="mt-1 block">
{DateTime.fromJSDate(document.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')}
</span>
</p>
<dd className="mt-1 text-foreground text-sm print:text-xs">
{DateTime.fromJSDate(document.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')}
</dd>
</div>
<p>
<span className="font-medium">{_(msg`Last Updated`)}</span>
<div>
<dt className="font-medium text-muted-foreground text-xs">{_(msg`Last Updated`)}</dt>
<span className="mt-1 block">
{DateTime.fromJSDate(document.updatedAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')}
</span>
</p>
<dd className="mt-1 text-foreground text-sm print:text-xs">
{DateTime.fromJSDate(document.updatedAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')}
</dd>
</div>
<p>
<span className="font-medium">{_(msg`Time Zone`)}</span>
<div>
<dt className="font-medium text-muted-foreground text-xs">{_(msg`Enclosed Document`)}</dt>
<span className="mt-1 block break-words">{document.documentMeta?.timezone ?? 'N/A'}</span>
</p>
<dd className="mt-1 break-words text-foreground text-sm print:text-xs">{document.title}</dd>
</div>
<div>
<p className="font-medium">{_(msg`Recipients`)}</p>
<div>
<dt className="font-medium text-muted-foreground text-xs">{_(msg`Recipients`)}</dt>
<dd className="mt-1 text-foreground text-sm print:text-xs">
<ul className="space-y-0.5">
<ul className="mt-1 list-inside list-disc">
{document.recipients.map((recipient) => (
<li key={recipient.id} className="break-words">
{recipient.name} ({recipient.email}) ·{' '}
<li key={recipient.id}>
<span className="text-muted-foreground">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</span>
[{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}]
</span>{' '}
{recipient.name} ({recipient.email})
</li>
))}
</ul>
</dd>
</div>
</dl>
</div>
</CardContent>
</Card>
<div className="mt-8">
<InternalAuditLogTable logs={auditLogs} />
@@ -9,8 +9,11 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { getTranslations } from '@documenso/lib/utils/i18n';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FieldType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { redirect } from 'react-router';
@@ -200,178 +203,165 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<header>
<h1 className="font-semibold text-lg tracking-tight">{_(msg`Signing Certificate`)}</h1>
<div className="flex items-center">
<h1 className="my-8 font-bold text-2xl">{_(msg`Signing Certificate`)}</h1>
</div>
<p className="mt-1 text-pretty text-muted-foreground text-sm">{document.title}</p>
</header>
<Card>
<CardContent className="p-0">
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>{_(msg`Signer Events`)}</TableHead>
<TableHead>{_(msg`Signature`)}</TableHead>
<TableHead>{_(msg`Details`)}</TableHead>
{/* <TableHead>Security</TableHead> */}
</TableRow>
</TableHeader>
<table className="mt-6 w-full">
<thead>
<tr className="border-border border-b">
<th className="w-[30%] pr-4 pb-2 text-left font-medium text-muted-foreground text-xs">
{_(msg`Signer Events`)}
</th>
<TableBody className="print:text-xs">
{document.recipients.map((recipient, i) => {
const logs = getRecipientAuditLogs(recipient.id);
const signature = getRecipientSignatureField(recipient.id);
<th className="w-[30%] pr-4 pb-2 text-left font-medium text-muted-foreground text-xs">
{_(msg`Signature`)}
</th>
return (
<TableRow key={i} className="print:break-inside-avoid">
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
<div className="break-all">{recipient.email}</div>
<p className="mt-2 text-muted-foreground text-sm print:text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
<th className="pb-2 text-left font-medium text-muted-foreground text-xs">{_(msg`Details`)}</th>
</tr>
</thead>
<p className="mt-2 text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
</TableCell>
<tbody className="divide-y divide-border">
{document.recipients.map((recipient, i) => {
const logs = getRecipientAuditLogs(recipient.id);
const signature = getRecipientSignatureField(recipient.id);
const isRejected = Boolean(logs.DOCUMENT_RECIPIENT_REJECTED[0]);
<TableCell truncate={false} className="w-[min-content] align-top">
{signature ? (
<>
<div
className="inline-block rounded-lg p-1"
style={{
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
}}
>
{signature.signature?.signatureImageAsBase64 && (
<img
src={`${signature.signature?.signatureImageAsBase64}`}
alt="Signature"
className="max-h-12 max-w-full"
/>
)}
return (
<tr key={i} className="align-top print:break-inside-avoid">
<td className="py-3 pr-4">
<div className="hyphens-auto break-words font-medium text-sm print:text-xs">{recipient.name}</div>
{signature.signature?.typedSignature && (
<p className="text-center font-signature text-sm">
{signature.signature?.typedSignature}
</p>
)}
</div>
<div className="break-all text-muted-foreground text-xs">{recipient.email}</div>
<div className="mt-0.5 text-muted-foreground text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</div>
<div className="mt-2.5">
<div className="font-medium text-muted-foreground text-xs">{_(msg`Authentication Level`)}</div>
<div className="mt-0.5 text-foreground text-sm print:text-xs">
{getAuthenticationLevel(recipient.id)}
</div>
</div>
</td>
<td className="py-3 pr-4">
{signature ? (
<div className="space-y-2.5">
{!isRejected && (
<div
className="inline-block rounded-lg p-1"
style={{
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
}}
>
{signature.signature?.signatureImageAsBase64 && (
<img
src={`${signature.signature?.signatureImageAsBase64}`}
alt="Signature"
className="max-h-12 max-w-full"
/>
)}
{signature.signature?.typedSignature && (
<p className="text-center font-signature text-sm">{signature.signature?.typedSignature}</p>
)}
</div>
<p className="mt-2 text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
<span className="block font-mono uppercase">{signature.secondaryId}</span>
</p>
</>
) : (
<p className="text-muted-foreground">
<Trans>N/A</Trans>
</p>
)}
<div>
<div className="font-medium text-muted-foreground text-xs">{_(msg`Signature ID`)}</div>
<p className="mt-2 text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
</span>
</p>
<div className="mt-0.5 break-all font-mono text-xs uppercase">{signature.secondaryId}</div>
<p className="mt-1 text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
</p>
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
<div className="space-y-1">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Sent`)}:</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0]
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: logs.DOCUMENT_SENT[0]
? DateTime.fromJSDate(logs.DOCUMENT_SENT[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0]
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
) : (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
)}
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="inline-block">
{recipient.signingStatus === SigningStatus.REJECTED
? recipient.rejectionReason
: _(
isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role],
)}
</span>
</p>
</div>
</div>
) : (
<p className="text-muted-foreground text-sm print:text-xs">{_(msg`N/A`)}</p>
)}
<div className="mt-2.5">
<div className="font-medium text-muted-foreground text-xs">{_(msg`IP Address`)}</div>
<div className="mt-0.5 text-foreground text-sm print:text-xs">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
</div>
</div>
<div className="mt-2.5">
<div className="font-medium text-muted-foreground text-xs">{_(msg`Device`)}</div>
<div className="mt-0.5 text-foreground text-sm print:text-xs">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</div>
</div>
</td>
<td className="py-3">
<div className="space-y-2.5">
<div>
<div className="font-medium text-muted-foreground text-xs">{_(msg`Sent`)}</div>
<div className="mt-0.5 text-foreground text-sm print:text-xs">
{logs.EMAIL_SENT[0]
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: logs.DOCUMENT_SENT[0]
? DateTime.fromJSDate(logs.DOCUMENT_SENT[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</div>
</div>
<div>
<div className="font-medium text-muted-foreground text-xs">{_(msg`Viewed`)}</div>
<div className="mt-0.5 text-foreground text-sm print:text-xs">
{logs.DOCUMENT_OPENED[0]
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</div>
</div>
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
<div>
<div className="font-medium text-red-600 text-xs">{_(msg`Rejected`)}</div>
<div className="mt-0.5 text-red-600 text-sm print:text-xs">
{DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')}
</div>
</div>
) : (
<div>
<div className="font-medium text-muted-foreground text-xs">{_(msg`Signed`)}</div>
<div className="mt-0.5 text-foreground text-sm print:text-xs">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</div>
</div>
)}
<div>
<div className="font-medium text-muted-foreground text-xs">{_(msg`Reason`)}</div>
<div className="mt-0.5 text-foreground text-sm print:text-xs">
{recipient.signingStatus === SigningStatus.REJECTED
? recipient.rejectionReason
: _(
isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role],
)}
</div>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{!hidePoweredBy && (
<div className="my-8 flex-row-reverse space-y-4">
@@ -387,7 +377,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</div>
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 font-medium text-muted-foreground text-xs">
<p className="flex-shrink-0 font-medium text-sm print:text-xs">
{_(msg`Signing certificate provided by`)}:
</p>
<BrandingLogo className="max-h-6 print:max-h-4" />
+4 -4
View File
@@ -36,8 +36,8 @@
"@lingui/react": "^5.6.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@react-router/node": "^7.18.1",
"@react-router/serve": "^7.18.1",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-query": "5.90.10",
@@ -81,8 +81,8 @@
"@babel/preset-typescript": "^7.28.5",
"@lingui/babel-plugin-lingui-macro": "^5.6.0",
"@lingui/vite-plugin": "^5.6.0",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@react-router/dev": "^7.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
+2048 -1264
View File
File diff suppressed because it is too large Load Diff
@@ -50,7 +50,7 @@ import type { Organisation, Team, User } from '@prisma/client';
*
* --- GLOBAL LIMIT AWARENESS ---
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
* apiV1RateLimit = 1000 requests / 1 minute (action `api.v1`, see rate-limits.ts).
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
* digits) and the suite runs serially so the shared-IP global bucket is never the
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
@@ -62,7 +62,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
// Run serially: all workers share one IP, and the global /api/v1 limiter is
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
test.describe.configure({ mode: 'serial' });
// This suite is only meaningful with real rate limiting enabled. CI sets the
@@ -125,7 +125,7 @@ const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
*
* - The org windowed limiter keys its rows `ip:org:<id>`.
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 1000/min
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
* Across the suite (and especially across repeated local runs within the same
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
@@ -37,7 +37,7 @@ import type { Organisation, Team, User } from '@prisma/client';
*
* --- GLOBAL LIMIT AWARENESS ---
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
* apiV2RateLimit = 1000 requests / 1 minute (see rate-limits.ts).
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
* digits) and the suite runs serially so the shared-IP global bucket is never the
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
@@ -49,7 +49,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
// Run serially: all workers share one IP, and the global /api/v2 limiter is
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
test.describe.configure({ mode: 'serial' });
// This suite is only meaningful with real rate limiting enabled. CI sets the
@@ -137,7 +137,6 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
}),
envelopeOwner,
envelopeId: envelope.id,
envelopeTitle: envelope.title,
qrToken: envelope.qrToken,
hidePoweredBy: organisationClaim.flags.hidePoweredBy ?? false,
pageWidth,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -84,19 +84,19 @@ export const syncSubscriptionRateLimit = createRateLimit({
export const apiV1RateLimit = createRateLimit({
action: 'api.v1',
max: 100,
max: 1000,
window: '1m',
});
export const apiV2RateLimit = createRateLimit({
action: 'api.v2',
max: 100,
max: 1000,
window: '1m',
});
export const apiTrpcRateLimit = createRateLimit({
action: 'api.trpc',
max: 100,
max: 1000,
window: '1m',
});