Compare commits

..

14 Commits

Author SHA1 Message Date
ephraimduncan 5e1d85bc33 fix(ui): add accessible selected-count label to bulk action bar 2026-07-02 12:01:27 +00:00
ephraimduncan 1102e833cc Merge remote-tracking branch 'origin/main' into pr-2711
# Conflicts:
#	apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx
#	apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx
#	packages/app-tests/e2e/documents/bulk-document-actions.spec.ts
2026-07-02 06:48:23 +00:00
Ephraim Duncan 814eb51b26 Merge branch 'main' into feat/bulk-download 2026-05-27 13:46:51 +00:00
ephraimduncan a16dd73ec1 Merge remote-tracking branch 'origin/main' into pr-2711
# Conflicts:
#	packages/lib/translations/de/web.po
#	packages/lib/translations/en/web.po
#	packages/lib/translations/es/web.po
#	packages/lib/translations/fr/web.po
#	packages/lib/translations/it/web.po
#	packages/lib/translations/ja/web.po
#	packages/lib/translations/ko/web.po
#	packages/lib/translations/nl/web.po
#	packages/lib/translations/pl/web.po
#	packages/lib/translations/pt-BR/web.po
#	packages/lib/translations/zh/web.po
2026-05-14 15:43:59 +00:00
ephraimduncan 5da2a2020e chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/bulk-download. Take PR's bulk action bar
design (escape-key dismiss, popover pill with count badge, optional
download button via onDownloadClick) and the documents page bulk
download state (envelopeMetaCache cached across pages, selected
envelopes mapped from cache for download).

Imports reordered into biome's @documenso → external → relative
groups; combined the @prisma/client import line to include
DocumentStatus as PrismaDocumentStatus alongside EnvelopeType,
FolderType, OrganisationType.
2026-05-12 11:50:10 +00:00
ephraimduncan d79b1d4612 fix: persist selected envelope metadata across pagination for bulk download 2026-04-22 17:23:49 +00:00
ephraimduncan 3685acc0ab fix: propagate createDocumentOptions.title to envelope item in seedDraftDocument 2026-04-20 21:58:46 +00:00
ephraimduncan eeea3651ee fix: update bulk action e2e tests for redesigned toolbar
The bulk action toolbar redesign split the count and "selected"
text into separate spans and renamed "Move to Folder" to "Move",
breaking getByText('N selected') and the toolbar button lookups.
Use /N\s*selected/ regex and scope the dialog submit to the
open dialog since the toolbar's "Move" button persists.
2026-04-20 21:27:58 +00:00
ephraimduncan 50997d7e92 chore: drop blur and transparency from bulk action bar
The translucent popover + backdrop blur muddied the pill against busy
table rows. Switch to an opaque background so the bar reads cleanly.
2026-04-20 20:42:32 +00:00
ephraimduncan bc97af14d3 chore: drop unused toolbar role and aria-label from bulk action bar 2026-04-20 19:08:30 +00:00
ephraimduncan 278dfa3d77 chore: deslop bulk download dialog and action bar
Merge duplicate lingui imports, drop redundant state-reset comment,
simplify Select onValueChange to match codebase style, and remove
broken tap-target span with invalid Tailwind 3.4 classes
(pointer-fine:, -translate-1/2).
2026-04-20 18:57:43 +00:00
ephraimduncan 5b64137237 feat: redesign bulk action toolbar and add escape-to-clear 2026-04-20 18:35:42 +00:00
ephraimduncan b9b29e5a76 fix: bulk download partial failure, abort, and race-safe e2e
- onSuccess now reports successful envelope ids so the parent clears
  only those rows from selection instead of wiping all pages.
- Partial failures no longer auto-close the dialog; failed/unprocessed
  ids stay selected for retry.
- Cancel button turns into Stop while downloading and aborts the batch
  at the next envelope boundary.
- Replace dual waitForEvent('download') with a page.on collector +
  expect.poll so both downloads are captured reliably.
2026-04-20 15:48:18 +00:00
ephraimduncan 5b63b5deb9 feat: bulk download documents 2026-04-20 15:04:59 +00:00
164 changed files with 4195 additions and 9517 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
legacy-peer-deps = true
prefer-dedupe = true
# min-release-age = 7
min-release-age = 7
@@ -65,4 +65,3 @@ When you exceed a resource limit:
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
- [API Versioning](/docs/developers/api/versioning) - API version management
- [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Admins: set per-organisation resource quotas and rate limits (the HTTP rate limit above is separate and not admin-settable)
@@ -76,8 +76,6 @@ 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>
@@ -199,7 +197,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. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance
4. Configure Enterprise features with support assistance
</Step>
<Step>
@@ -240,7 +238,6 @@ 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
@@ -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. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup.
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.
| Variable | Description |
| ------------------------------------ | ------------------------------------------------ |
| `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_DOCUMENSO_LICENSE_KEY` | License key for enterprise features |
| `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 |
@@ -510,5 +510,4 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
- [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery
- [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing
- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Set per-organisation document, email, and API limits from the admin panel
- [Troubleshooting](/docs/self-hosting/maintenance/troubleshooting) - Common configuration issues
@@ -29,11 +29,6 @@ description: Configure your self-hosted Documenso instance with environment vari
description="Digital signature certificate setup."
href="/docs/self-hosting/configuration/signing-certificate"
/>
<Card
title="Organisation Limits"
description="Set per-organisation document, email, and API limits via the admin panel."
href="/docs/self-hosting/configuration/organisation-limits"
/>
</Cards>
## Required Configuration
@@ -1,107 +0,0 @@
---
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,14 +2,12 @@
"title": "Configuration",
"pages": [
"environment",
"license",
"database",
"email",
"storage",
"background-jobs",
"signing-certificate",
"telemetry",
"organisation-limits",
"advanced"
]
}
@@ -1,111 +0,0 @@
---
title: Organisation Limits
description: View and set per-organisation document, email, and API limits on a self-hosted Documenso instance using the admin panel's subscription claims.
---
import { Callout } from 'fumadocs-ui/components/callout';
Per-organisation limits — document, email, and API usage, plus feature toggles and team/member caps — are controlled by **subscription claims**. You configure them in the admin panel, not through environment variables.
There are three distinct kinds of limit:
| Limit | Caps | Admin-settable |
| ---------------------- | ------------------------------------------------- | ----------------------- |
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) |
## Prerequisites
- A running self-hosted Documenso instance.
- An account with the **`ADMIN`** role — an account-level role, separate from organisation and team roles. New accounts are created with the `USER` role only. Grant the first admin by adding `ADMIN` to that user's `roles` directly in the database; after that, an existing admin can grant the role to others under **Admin Panel > Users > _(user)_ > Roles > Update user**.
Open the admin panel at `/admin`. The sidebar sections used below are **Claims**, **Organisations**, and **Organisation Stats**.
## Viewing usage
**One organisation:** open **Admin Panel > Organisations** and select it. The **Organisation usage** section shows the current period's document, email, and API usage against its quotas.
**All organisations:** open **Admin Panel > Organisation Stats** to sort and filter monthly usage. Filter by **claim** and by **period** (a UTC calendar month, shown as `YYYY-MM`), and switch between **Show usage**, **Show usage with quotas**, and **Show daily averages**.
<Callout type="warn">
Usage counts **attempts**, not only successful actions. A request that exceeds a quota is still counted before it is rejected, so displayed usage can read higher than the number of actions that succeeded.
</Callout>
## Subscription claims
A subscription claim is a named bundle of limits and feature flags (for example `Free`, `Individual`, `Teams`, `Platform`, or `Enterprise`). Claims are **templates**: when an organisation is created it receives a private copy of its claim and reads from that copy afterwards. Editing a claim template therefore affects organisations created later, not existing ones — to change an existing organisation, [edit it directly](#change-limits-for-one-organisation).
### Claim fields
Under **Admin Panel > Claims** (`/admin/claims`), each claim has:
| Field | Controls |
| ----------------------- | --------------------------------------------------------------------------------- |
| **Name** | The claim's display name. |
| **Team Count** | Teams allowed. `0` = unlimited. |
| **Member Count** | Members allowed. `0` = unlimited. |
| **Envelope Item Count** | Uploaded files allowed per envelope. Minimum `1`. |
| **Recipient Count** | Recipients allowed per document. `0` = unlimited. |
| **Feature Flags** | Feature toggles (see [Feature flags](#feature-flags)). |
| **Limits** | Monthly quota and rate-limit windows for Documents, Emails, and API. |
| **Email transport** | Transport the claim uses. *Default (system mailer)* uses the instance default. |
### Quotas and rate limits
The **Limits** section has a column for **Documents**, **Emails**, and **API**, each with two controls:
- **Monthly quota** — how many of that resource are allowed per calendar month. An **empty** field is unlimited; **`0`** blocks the resource entirely.
- **Rate limit windows** — optional short-window caps, each a duration and a maximum. A window is a number and a unit (`s`, `m`, `h`, `d`), such as `5m`, `1h`, or `24h`, and must be unique within the resource.
<Callout type="warn">
Quotas and counts use opposite conventions for "unlimited": an **empty** quota is unlimited (and `0` blocks the resource), whereas `0` in the **Team**, **Member**, and **Recipient Count** fields means unlimited.
</Callout>
### Feature flags
The **Feature Flags** section toggles capabilities such as Unlimited documents, Branding, Hide Documenso branding, Email domains, Embed authoring, Embed signing, White label for embed authoring/signing, 21 CFR, HIPAA, Authentication portal, Allow Legacy Envelopes, Signing reminders, QES signing, and Disable emails.
Some flags are Enterprise features. If your license does not include one, it is marked and cannot be enabled (you can still turn it off). See [Enterprise Edition](/docs/policies/enterprise-edition).
### Create or edit a claim template
1. Go to **Admin Panel > Claims**.
2. Select **New claim**, or select an existing claim to edit it.
3. Set the counts, feature flags, and the **Limits** section.
4. Save. Changes apply to organisations created afterwards, not existing ones.
### Change limits for one organisation
To change limits for an existing organisation, edit it directly rather than its claim template.
1. Go to **Admin Panel > Organisations** and open the organisation.
2. Adjust its quota, rate-limit, feature-flag, or email-transport fields.
3. Save. Changes take effect immediately.
The organisation also shows the **Inherited subscription claim** it was created from.
## Usage reset
Monthly quota usage is keyed to the **UTC calendar month**. There is no scheduled reset job — when the month rolls over, the new period's counter starts at `0`.
## Limitations
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
## Troubleshooting
| Symptom | Cause and fix |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| An organisation hit its limit unexpectedly | Usage counts rejected over-quota attempts. Compare usage against the quota under **Organisation Stats > Show usage with quotas**. |
| A resource is blocked entirely, not just capped | The **Monthly quota** is `0`, which blocks the resource. Leave it empty for unlimited. |
| Emails are not sending for an organisation | Check whether the **Disable emails** flag is enabled on the organisation's claim — it blocks all emails regardless of quota. |
| A claim template edit had no effect | Template edits are not retroactive. Edit the organisation directly under **Admin Panel > Organisations**. |
---
## See Also
- [Environment Variables](/docs/self-hosting/configuration/environment) - All configuration options
- [Rate Limits](/docs/developers/api/rate-limits) - The global HTTP API rate limit (separate from claims)
- [Enterprise Edition](/docs/policies/enterprise-edition) - Features unlocked by license flags
@@ -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`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one.
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</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. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license).
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
---
+2 -2
View File
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"scripts": {
"build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build",
"build": "next build",
"dev": "next dev",
"start": "next start",
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
@@ -29,7 +29,7 @@
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.19",
"postcss": "^8.5.14",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
+1 -1
View File
@@ -83,7 +83,7 @@
--accent: hsl(0 0% 27.8431%);
--accent-foreground: hsl(95.0847 71.0843% 67.451%);
--destructive: hsl(0 86.5979% 61.9608%);
--destructive-foreground: hsl(0 0% 98.0392%);
--destructive-foreground: hsl(0 87.6289% 19.0196%);
--border: hsl(0 0% 27.8431%);
--input: hsl(0 0% 27.8431%);
--ring: hsl(95.0847 71.0843% 67.451%);
@@ -1,119 +0,0 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type BrandingPreferencesResetDialogProps = {
hasAdvancedBranding: boolean;
isSubmitting: boolean;
onReset: () => Promise<void>;
trigger?: React.ReactNode;
};
export const BrandingPreferencesResetDialog = ({
hasAdvancedBranding,
isSubmitting,
onReset,
trigger,
}: BrandingPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isLoading = isSubmitting || isResetting;
const handleResetToDefaults = async () => {
setIsResetting(true);
try {
await onReset();
setOpen(false);
} catch {
// The submit handler surfaces its own error toast. Keep the dialog open
// so the user can retry.
} finally {
setIsResetting(false);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
<Trans>Reset to defaults</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Reset branding preferences</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will reset all branding preferences to their default values and save the changes immediately.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will be reset:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>Custom branding enabled setting</Trans>
</li>
<li>
<Trans>Branding logo</Trans>
</li>
<li>
<Trans>Brand website and brand details</Trans>
</li>
<li>
<Trans>Brand colours, including background, foreground, primary, and border colours</Trans>
</li>
{hasAdvancedBranding && (
<>
<li>
<Trans>Border radius</Trans>
</li>
<li>
<Trans>Custom CSS</Trans>
</li>
</>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,141 +0,0 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type DocumentPreferencesResetDialogProps = {
isSubmitting: boolean;
onReset: () => Promise<void>;
showAiFeatures?: boolean;
showDocumentVisibility?: boolean;
showIncludeSenderDetails?: boolean;
};
export const DocumentPreferencesResetDialog = ({
isSubmitting,
onReset,
showAiFeatures = false,
showDocumentVisibility = false,
showIncludeSenderDetails = false,
}: DocumentPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isLoading = isSubmitting || isResetting;
const handleResetToDefaults = async () => {
setIsResetting(true);
try {
await onReset();
setOpen(false);
} catch {
// The submit handler surfaces its own error toast. Keep the dialog open
// so the user can retry.
} finally {
setIsResetting(false);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Reset document preferences</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will reset all document preferences to their default values and save the changes immediately.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will be reset:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
{showDocumentVisibility && (
<li>
<Trans>Default document visibility</Trans>
</li>
)}
<li>
<Trans>Default document language</Trans>
</li>
<li>
<Trans>Default date format</Trans>
</li>
<li>
<Trans>Default time zone</Trans>
</li>
<li>
<Trans>Default signature settings</Trans>
</li>
{showIncludeSenderDetails && (
<li>
<Trans>Send on behalf of team</Trans>
</li>
)}
<li>
<Trans>Include the signing certificate in the document</Trans>
</li>
<li>
<Trans>Include the audit logs in the document</Trans>
</li>
<li>
<Trans>Default recipients</Trans>
</li>
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Default envelope expiration</Trans>
</li>
<li>
<Trans>Default signing reminders</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
</li>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,284 @@
import { useEffect, useRef, useState } from 'react';
import { plural } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopeBulkDownloadItem = {
id: string;
title: string;
status: DocumentStatus;
};
export type EnvelopesBulkDownloadDialogProps = {
envelopes: EnvelopeBulkDownloadItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (successfulEnvelopeIds: string[]) => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkDownloadDialog = ({
envelopes,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDownloadDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [versionMap, setVersionMap] = useState<Record<string, 'signed' | 'original'>>({});
const [progress, setProgress] = useState(0);
const [isDownloading, setIsDownloading] = useState(false);
const abortRef = useRef(false);
const trpcUtils = trpc.useUtils();
useEffect(() => {
if (!open) {
return;
}
setVersionMap(
Object.fromEntries(
envelopes.map((envelope) => [
envelope.id,
envelope.status === DocumentStatus.COMPLETED ? 'signed' : 'original',
]),
),
);
setProgress(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const getStatusLabel = (status: DocumentStatus) =>
match(status)
.with(DocumentStatus.COMPLETED, () => t`Completed`)
.with(DocumentStatus.PENDING, () => t`Pending`)
.with(DocumentStatus.DRAFT, () => t`Draft`)
.with(DocumentStatus.REJECTED, () => t`Rejected`)
.with(DocumentStatus.CANCELLED, () => t`Cancelled`)
.exhaustive();
const onDownload = async () => {
if (envelopes.length === 0 || isDownloading) {
return;
}
abortRef.current = false;
setIsDownloading(true);
setProgress(0);
const successfulEnvelopeIds: string[] = [];
let failedDownloads = 0;
try {
for (const envelope of envelopes) {
if (abortRef.current) {
break;
}
try {
const downloadVersion = versionMap[envelope.id] ?? 'original';
const { data: envelopeItems } = await trpcUtils.envelope.item.getManyByToken.fetch({
envelopeId: envelope.id,
access: {
type: 'user',
},
});
for (const envelopeItem of envelopeItems) {
await downloadPDF({
envelopeItem,
token: undefined,
fileName: envelopeItem.title,
version: downloadVersion,
});
}
successfulEnvelopeIds.push(envelope.id);
} catch (error) {
console.error(error);
failedDownloads++;
}
setProgress((p) => p + 1);
}
if (successfulEnvelopeIds.length === 0) {
toast({
title: t`Error`,
description: t`An error occurred while downloading the documents.`,
variant: 'destructive',
});
return;
}
if (failedDownloads > 0) {
toast({
title: t`Documents partially downloaded`,
description: t`${plural(successfulEnvelopeIds.length, {
one: '# document downloaded.',
other: '# documents downloaded.',
})} ${plural(failedDownloads, {
one: '# document could not be downloaded.',
other: '# documents could not be downloaded.',
})}`,
variant: 'destructive',
});
onSuccess?.(successfulEnvelopeIds);
return;
}
toast({
title: t`Documents downloaded`,
description: plural(successfulEnvelopeIds.length, {
one: '# document has been downloaded.',
other: '# documents have been downloaded.',
}),
});
onSuccess?.(successfulEnvelopeIds);
onOpenChange(false);
} finally {
setIsDownloading(false);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => {
if (!isDownloading) {
onOpenChange(value);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Download Documents</Trans>
</DialogTitle>
<DialogDescription>
<Plural
value={envelopes.length}
one="Select the version to download for the selected document."
other="Select the version to download for each of the # selected documents."
/>
</DialogDescription>
</DialogHeader>
<fieldset disabled={isDownloading} className="space-y-4">
<div className="max-h-96 space-y-2 overflow-y-auto">
{envelopes.map((envelope) => {
const isCompleted = envelope.status === DocumentStatus.COMPLETED;
return (
<div
key={envelope.id}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-3"
>
<div className="min-w-0 flex-1">
<p
className="truncate text-sm font-medium text-foreground"
title={envelope.title}
>
{envelope.title}
</p>
<p className="text-xs text-muted-foreground">
{getStatusLabel(envelope.status)}
</p>
</div>
{isCompleted && (
<Select
value={versionMap[envelope.id] ?? 'signed'}
onValueChange={(value) =>
setVersionMap((prev) => ({
...prev,
[envelope.id]: value as 'signed' | 'original',
}))
}
>
<SelectTrigger className="w-[120px] flex-shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="signed">
<Trans context="Signed document (adjective)">Signed</Trans>
</SelectItem>
<SelectItem value="original">
<Trans context="Original document (adjective)">Original</Trans>
</SelectItem>
</SelectContent>
</Select>
)}
</div>
);
})}
</div>
{isDownloading && (
<p className="text-sm text-muted-foreground">
<Trans>
Downloading {progress} / {envelopes.length}...
</Trans>
</p>
)}
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
if (isDownloading) {
abortRef.current = true;
} else {
onOpenChange(false);
}
}}
>
{isDownloading ? <Trans>Stop</Trans> : <Trans>Cancel</Trans>}
</Button>
<Button
type="button"
onClick={() => void onDownload()}
loading={isDownloading}
disabled={envelopes.length === 0}
>
<Trans>Download</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -24,7 +23,7 @@ import { useParams } from 'react-router';
import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({
name: ZNameSchema,
name: z.string().min(1, { message: 'Folder name is required' }),
});
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
@@ -66,7 +65,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }:
toast({
description: t`Folder created successfully`,
});
} catch (_err) {
} catch (err) {
toast({
title: t`Failed to create folder`,
description: t`An unknown error occurred while creating the folder.`,
@@ -122,7 +122,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -1,6 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,6 +23,8 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
export type FolderUpdateDialogProps = {
folder: TFolderWithSubfolders | null;
isOpen: boolean;
@@ -31,7 +32,7 @@ export type FolderUpdateDialogProps = {
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const ZUpdateFolderFormSchema = z.object({
name: ZNameSchema,
name: z.string().min(1),
visibility: z.nativeEnum(DocumentVisibility).optional(),
});
@@ -39,6 +40,7 @@ export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => {
const { t } = useLingui();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
@@ -336,7 +336,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }:
>
<div className="w-full text-left">
<div className="flex items-center justify-between">
<p className="font-medium">
<p className="text-medium">
<Trans context="Plan price">Free</Trans>
</p>
@@ -115,7 +115,7 @@ export const OrganisationEmailDomainDeleteDialog = ({
<FormLabel>
<Trans>
Confirm by typing{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -370,7 +370,7 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati
<button
type="button"
className={cn(
'inline-flex h-10 w-10 items-center justify-start text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
index === 0 ? 'mt-8' : 'mt-0',
)}
disabled={organisationMemberInvites.length === 1}
@@ -1,6 +1,5 @@
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -26,13 +25,14 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { z } from 'zod';
export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({
passkeyName: ZNameSchema,
passkeyName: z.string().min(3),
});
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
@@ -1,5 +1,4 @@
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamEmailMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -20,16 +19,16 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({
data: true,
}).shape.data;
const ZUpdateTeamEmailFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
});
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
@@ -45,7 +44,6 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai
defaultValues: {
name: teamEmail.name,
},
mode: 'onSubmit',
});
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
@@ -1,250 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
const NEVER_EXPIRE = 'NEVER' as const;
export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`,
THREE_MONTHS: msg`3 months`,
SIX_MONTHS: msg`6 months`,
ONE_YEAR: msg`12 months`,
[NEVER_EXPIRE]: msg`Never`,
} as const;
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
tokenName: true,
expirationDate: true,
});
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
export type TokenCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const TokenCreateDialog = ({ trigger, ...props }: TokenCreateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<string | null>(null);
const form = useForm<TCreateTokenFormSchema>({
resolver: zodResolver(ZCreateTokenFormSchema),
defaultValues: {
tokenName: '',
expirationDate: 'THREE_MONTHS',
},
});
const { mutateAsync: createToken } = trpc.apiToken.create.useMutation();
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
try {
const { token } = await createToken({
teamId: team.id,
tokenName,
expirationDate: expirationDate === NEVER_EXPIRE ? null : expirationDate,
});
setCreatedToken(token);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
.otherwise(() => msg`Something went wrong. Please try again later.`);
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
duration: 5000,
});
}
};
useEffect(() => {
if (open) {
form.reset();
setCreatedToken(null);
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="flex-shrink-0">
<Trans>Create token</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent
className="max-w-lg"
position="center"
onInteractOutside={(event) => {
// Prevent losing the created token by accidentally clicking outside the dialog.
if (createdToken) {
event.preventDefault();
}
}}
>
{createdToken ? (
<>
<DialogHeader>
<DialogTitle>
<Trans>Token created</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Copy your token now. For security reasons you will not be able to see it again.</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Input
className="pr-12 font-mono text-sm"
aria-label={_(msg`Your new API token`)}
name="createdToken"
readOnly
value={createdToken}
/>
<div className="absolute top-0 right-2 bottom-0 flex items-center justify-center">
<CopyTextButton
value={createdToken}
onCopySuccess={() => toast({ title: _(msg`Token copied to clipboard`) })}
/>
</div>
</div>
<DialogFooter>
<Button type="button" onClick={() => setOpen(false)}>
<Trans>Done</Trans>
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>
<Trans>Create API token</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Use API tokens to authenticate with the Documenso API.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
<Trans>A name to help you identify this token later.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Expires in</Trans>
</FormLabel>
<FormControl>
<Select value={field.value ?? NEVER_EXPIRE} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{_(date)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Delete token</Trans>
<Trans>Are you sure you want to delete this token?</Trans>
</DialogTitle>
<DialogDescription>
@@ -126,7 +126,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
@@ -139,18 +139,21 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setIsOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<div className="flex w-full flex-nowrap gap-4">
<Button type="button" variant="secondary" className="flex-1" onClick={() => setIsOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>Delete</Trans>
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>I'm sure! Delete it</Trans>
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
@@ -117,7 +117,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-semibold text-destructive text-sm">{deleteMessage}</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -503,7 +503,7 @@ export const ConfigureFieldsView = ({
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted-background',
selectedRecipientStyles.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -1,13 +1,7 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
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 { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,21 +18,20 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { BrandingPreferencesResetDialog } from '~/components/dialogs/branding-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
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({
brandingEnabled: z.boolean().nullable(),
brandingLogo: z
.instanceof(File)
.refine(
(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')
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
@@ -76,7 +69,6 @@ export function BrandingPreferencesForm({
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
const [colorPickerKey, setColorPickerKey] = useState(0);
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
const initialColors = parsedColors.success ? parsedColors.data : {};
@@ -99,42 +91,6 @@ export function BrandingPreferencesForm({
const isBrandingEnabled = form.watch('brandingEnabled');
const hasResetBrandingColors =
settings.brandingColors === null ||
settings.brandingColors === undefined ||
(parsedColors.success && normalizeBrandingColors(parsedColors.data) === null);
// Only show the reset action when the saved settings actually differ from the
// defaults, so it never renders as a pointless disabled button.
const isResetToDefaultsVisible =
settings.brandingEnabled !== (canInherit ? null : false) ||
!!settings.brandingLogo ||
!!settings.brandingUrl ||
!!settings.brandingCompanyDetails ||
!!settings.brandingCss ||
!hasResetBrandingColors;
const handleResetToDefaults = async () => {
const data: TBrandingPreferencesFormSchema = {
brandingEnabled: canInherit ? null : false,
brandingLogo: null,
brandingUrl: '',
brandingCompanyDetails: '',
brandingColors: {},
brandingCss: '',
};
await onFormSubmit(data);
if (previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl('');
setColorPickerKey((key) => key + 1);
form.reset(data);
};
const getSavedLogoPreviewUrl = () => {
if (!settings.brandingLogo) {
return '';
@@ -289,7 +245,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative">
<Input
type="file"
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
accept={ACCEPTED_FILE_TYPES.join(',')}
disabled={!isBrandingEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
@@ -436,7 +392,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`background-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.background}
@@ -460,7 +415,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`foreground-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.foreground}
@@ -484,7 +438,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`primary-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primary}
@@ -508,7 +461,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`primary-foreground-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
@@ -532,7 +484,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`border-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.border}
@@ -556,7 +507,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`ring-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.ring}
@@ -638,15 +588,6 @@ export function BrandingPreferencesForm({
isDirty={hasUnsavedChanges}
isSubmitting={form.formState.isSubmitting}
onReset={handleReset}
resetToDefaults={
isResetToDefaultsVisible ? (
<BrandingPreferencesResetDialog
hasAdvancedBranding={hasAdvancedBranding}
isSubmitting={form.formState.isSubmitting}
onReset={handleResetToDefaults}
/>
) : undefined
}
/>
</fieldset>
</form>
@@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
@@ -37,11 +37,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
@@ -93,26 +93,6 @@ export type DocumentPreferencesFormProps = {
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => {
const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat);
return {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
};
};
export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
@@ -133,7 +113,7 @@ export const DocumentPreferencesForm = ({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
@@ -147,33 +127,26 @@ export const DocumentPreferencesForm = ({
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const defaultValues = getDocumentPreferencesFormValues(settings);
const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings();
const baseResetValues = getDocumentPreferencesFormValues(defaultSettings);
const resetValues = {
...baseResetValues,
aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled,
};
const form = useForm<TDocumentPreferencesFormSchema>({
defaultValues,
defaultValues: {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
// Parse both sides through the schema so we compare canonical representations
const parsedCurrentValues = ZDocumentPreferencesFormSchema.safeParse(defaultValues);
const parsedResetValues = ZDocumentPreferencesFormSchema.safeParse(resetValues);
const isResetToDefaultsVisible =
!parsedCurrentValues.success ||
!parsedResetValues.success ||
JSON.stringify(parsedCurrentValues.data) !== JSON.stringify(parsedResetValues.data);
const handleResetToDefaults = async () => {
await onFormSubmit(resetValues);
form.reset(resetValues);
};
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
@@ -799,17 +772,6 @@ export const DocumentPreferencesForm = ({
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
resetToDefaults={
isResetToDefaultsVisible ? (
<DocumentPreferencesResetDialog
isSubmitting={form.formState.isSubmitting}
onReset={handleResetToDefaults}
showAiFeatures={isAiFeaturesConfigured}
showDocumentVisibility={!isPersonalLayoutMode}
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
/>
) : undefined
}
/>
</fieldset>
</form>
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import {
Form,
FormControl,
@@ -16,8 +15,8 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEmailTransportFormSchema = z.object({
name: ZNameSchema,
fromName: ZNameSchema,
name: z.string().min(1),
fromName: z.string().min(1),
fromAddress: z.string().email(),
type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']),
host: z.string().optional(),
@@ -3,17 +3,12 @@ import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon } from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
export type FormStickySaveBarProps = {
isDirty: boolean;
isSubmitting: boolean;
onReset: () => void;
/**
* Slot for a "reset to defaults" action, rendered before the Undo button. Hidden while
* the bar is floating so it never appears in the unsaved-changes island.
*/
resetToDefaults?: ReactNode;
};
/**
@@ -29,7 +24,7 @@ export type FormStickySaveBarProps = {
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
* the pill chrome.
*/
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => {
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
const { t } = useLingui();
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -105,8 +100,6 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefau
</AnimatePresence>
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
{!isFloating && resetToDefaults}
{isDirty && (
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
<Trans>Undo</Trans>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
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 { authClient } from '@documenso/auth/client';
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 { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -96,7 +96,7 @@ export const SignUpForm = ({
password: '',
signature: '',
},
mode: 'onChange',
mode: 'onBlur',
resolver: zodResolver(ZSignUpFormSchema),
});
+254
View File
@@ -0,0 +1,254 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ApiToken } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export const EXPIRATION_DATES = {
ONE_WEEK: msg`7 days`,
ONE_MONTH: msg`1 month`,
THREE_MONTHS: msg`3 months`,
SIX_MONTHS: msg`6 months`,
ONE_YEAR: msg`12 months`,
} as const;
const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({
tokenName: true,
expirationDate: true,
});
type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
type NewlyCreatedToken = {
id: number;
token: string;
};
export type ApiTokenFormProps = {
className?: string;
tokens?: Pick<ApiToken, 'id'>[];
};
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const [, copy] = useCopyToClipboard();
const team = useCurrentTeam();
const { _ } = useLingui();
const { toast } = useToast();
const [newlyCreatedToken, setNewlyCreatedToken] = useState<NewlyCreatedToken | null>();
const [noExpirationDate, setNoExpirationDate] = useState(false);
const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({
onSuccess(data) {
setNewlyCreatedToken(data);
},
});
const form = useForm<TCreateTokenFormSchema>({
resolver: zodResolver(ZCreateTokenFormSchema),
defaultValues: {
tokenName: '',
expirationDate: '',
},
});
const copyToken = async (token: string) => {
try {
const copied = await copy(token);
if (!copied) {
throw new Error('Unable to copy the token');
}
toast({
title: _(msg`Token copied to clipboard`),
description: _(msg`The token was copied to your clipboard.`),
});
} catch (error) {
toast({
title: _(msg`Unable to copy token`),
description: _(msg`We were unable to copy the token to your clipboard. Please try again.`),
variant: 'destructive',
});
}
};
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => {
try {
await createTokenMutation({
teamId: team.id,
tokenName,
expirationDate: noExpirationDate ? null : expirationDate,
});
toast({
title: _(msg`Token created`),
description: _(msg`A new token was created successfully.`),
duration: 5000,
});
form.reset();
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`)
.otherwise(() => msg`Something went wrong. Please try again later.`);
toast({
title: _(msg`An error occurred`),
description: _(errorMessage),
variant: 'destructive',
duration: 5000,
});
}
};
return (
<div className={cn(className)}>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">
<Trans>Token name</Trans>
</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Input type="text" {...field} />
</FormControl>
</div>
<FormDescription className="text-xs italic">
<Trans>Please enter a meaningful name for your token. This will help you identify it later.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="text-muted-foreground">
<Trans>Token expiration date</Trans>
</FormLabel>
<div className="flex items-center gap-x-4">
<FormControl className="flex-1">
<Select onValueChange={field.onChange} disabled={noExpirationDate}>
<SelectTrigger className="w-full">
<SelectValue placeholder={_(msg`Choose...`)} />
</SelectTrigger>
<SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}>
{_(date)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel className="mt-2 text-muted-foreground">
<Trans>Never expire</Trans>
</FormLabel>
<div className="block md:py-1.5">
<Switch
className="mt-2 bg-background"
checked={noExpirationDate}
onCheckedChange={setNoExpirationDate}
/>
</div>
</div>
</div>
<Button type="submit" className="hidden md:inline-flex" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
<div className="md:hidden">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Create token</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
<AnimatePresence>
{newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: -40 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 40 }}
>
<Card gradient>
<CardContent className="p-4">
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Your token was created successfully! Make sure to copy it because you won't be able to see it again!
</Trans>
</p>
<p className="my-4 rounded-md bg-muted-foreground/10 px-2.5 py-1 font-mono text-sm">
{newlyCreatedToken.token}
</p>
<Button variant="outline" onClick={() => void copyToken(newlyCreatedToken.token)}>
<Trans>Copy token</Trans>
</Button>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
import type { ReactNode } from 'react';
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
@@ -26,72 +25,38 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
type AdminGlobalSettingsSectionProps = {
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
isTeam?: boolean;
/** When viewing a team, the parent organisation settings the team inherits from. */
inheritedSettings?: OrganisationGlobalSettings | null;
};
export const AdminGlobalSettingsSection = ({
settings,
isTeam = false,
inheritedSettings,
}: AdminGlobalSettingsSectionProps) => {
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
const { _ } = useLingui();
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
if (!settings) {
return null;
}
const notSet = <Trans>Not set</Trans>;
const inheritedValue = (value: ReactNode) => {
if (!isTeam || value === null) {
return notSet;
const textValue = (value: string | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
return (
<span className="flex items-center gap-1.5">
<span className="text-muted-foreground">
<Trans>Inherited</Trans>:
</span>
<span>{value}</span>
</span>
);
return value;
};
const textValue = (value: string | null | undefined, inherited?: string | null) => {
if (value && value.trim() !== '') {
return value;
const brandingTextValue = (value: string | null | undefined) => {
if (value === null || value === undefined || value.trim() === '') {
return notSetLabel;
}
if (inherited && inherited.trim() !== '') {
return inheritedValue(inherited);
}
return notSet;
return value;
};
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
if (value !== null && value !== undefined) {
return booleanLabel(value);
const booleanValue = (value: boolean | null | undefined) => {
if (value === null || value === undefined) {
return notSetLabel;
}
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));
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
};
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
@@ -100,82 +65,70 @@ export const AdminGlobalSettingsSection = ({
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<DetailsCard label={<Trans>Document visibility</Trans>}>
<DetailsValue>
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
{settings.documentVisibility != null
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
: notSetLabel}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document language</Trans>}>
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Document timezone</Trans>}>
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Date format</Trans>}>
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include sender details</Trans>}>
<DetailsValue>
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
<DetailsValue>
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Include audit log</Trans>}>
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
<DetailsValue>
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Upload signature</Trans>}>
<DetailsValue>
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Draw signature</Trans>}>
<DetailsValue>
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
</DetailsValue>
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding logo</Trans>}>
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding URL</Trans>}>
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding company details</Trans>}>
<DetailsValue>
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
</DetailsValue>
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Email reply-to</Trans>}>
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
</DetailsCard>
{isTeam && parsedEmailSettings.success && (
@@ -192,7 +145,7 @@ export const AdminGlobalSettingsSection = ({
)}
<DetailsCard label={<Trans>AI features</Trans>}>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
</DetailsCard>
</div>
);
@@ -87,7 +87,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
</div>
<h3 className="mb-2 flex items-end font-medium text-foreground text-sm leading-tight">
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">
<Trans>Documenso License</Trans>
</h3>
@@ -1,7 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
@@ -20,6 +19,7 @@ import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = {
@@ -30,7 +30,7 @@ export type ClaimAccountProps = {
export const ZClaimAccountFormSchema = z
.object({
name: ZNameSchema,
name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }),
email: zEmail().min(1),
password: ZPasswordSchema,
})
@@ -1,4 +1,11 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import type { ReactNode } from 'react';
@@ -6,13 +13,6 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
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> = {
control: Control<T>;
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
@@ -20,12 +20,6 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
disabled?: boolean;
};
type LimitGroup = {
title: ReactNode;
quotaKey: string;
rateLimitKey: string;
};
export const ClaimLimitFields = <T extends FieldValues>({
control,
prefix = '',
@@ -36,33 +30,13 @@ export const ClaimLimitFields = <T extends FieldValues>({
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const name = (key: string) => `${prefix}${key}` as Path<T>;
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) => (
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
<FormField
control={control}
name={name(group.quotaKey)}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground text-xs">
<Trans>Monthly quota</Trans>
</FormLabel>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
type="number"
@@ -73,18 +47,20 @@ export const ClaimLimitFields = <T extends FieldValues>({
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>{description}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
const renderRateLimitField = (group: LimitGroup) => (
const renderRateLimitField = (key: string, label: ReactNode) => (
<FormField
control={control}
name={name(group.rateLimitKey)}
name={name(key)}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
</FormControl>
@@ -95,30 +71,27 @@ export const ClaimLimitFields = <T extends FieldValues>({
);
return (
<div className="space-y-3">
<div>
<h3 className="font-semibold text-base">
<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>
<div className="space-y-4 rounded-md border p-4">
<FormLabel>
<Trans>Limits</Trans>
</FormLabel>
<div className="overflow-hidden rounded-lg border">
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
{limitGroups.map((group) => (
<div key={group.quotaKey} className="space-y-4 p-4">
<h4 className="font-semibold text-sm">{group.title}</h4>
{renderQuotaField(
'documentQuota',
<Trans>Monthly document quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
{renderQuotaField(group)}
{renderRateLimitField(group)}
</div>
))}
</div>
</div>
{renderQuotaField(
'emailQuota',
<Trans>Monthly email quota</Trans>,
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
)}
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
</div>
);
};
@@ -1,23 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
export const DirectTemplateInvalidPageView = () => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
<h1 className="mt-4 font-semibold text-3xl">
<Trans>Invalid direct link template</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field assigned.
Please contact the sender to update the template.
</Trans>
</p>
</div>
</div>
);
};
@@ -270,7 +270,7 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted-background',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
@@ -54,7 +54,6 @@ import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
@@ -239,8 +238,6 @@ export const EnvelopeEditorFieldsPage = () => {
}
/>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
@@ -1,55 +0,0 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
export type EnvelopeEditorInvalidDirectTemplateAlertProps = {
className?: string;
};
/**
* Warns that a direct link template cannot be used because one or more signers
* are missing a signature field.
*/
export const EnvelopeEditorInvalidDirectTemplateAlert = ({
className,
}: EnvelopeEditorInvalidDirectTemplateAlertProps) => {
const { envelope, isTemplate } = useCurrentEnvelopeEditor();
const signersMissingSignatureFields = useMemo(() => {
if (!isTemplate || !envelope.directLink?.enabled) {
return [];
}
return getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
}, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]);
if (signersMissingSignatureFields.length === 0) {
return null;
}
return (
<Alert
variant="destructive"
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
>
<AlertTitle>
<Trans>Invalid direct link template</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Recipients cannot use this direct link template because the following signers are missing a signature field
</Trans>
<ul className="list-disc pl-5">
{signersMissingSignatureFields.map((recipient, i) => (
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
};
@@ -22,7 +22,6 @@ import { match } from 'ts-pattern';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
export const EnvelopeEditorPreviewPage = () => {
@@ -229,8 +228,6 @@ export const EnvelopeEditorPreviewPage = () => {
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
<Alert variant="warning" className="mx-auto max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
@@ -26,7 +26,6 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
@@ -450,9 +449,6 @@ export const EnvelopeEditorUploadPage = () => {
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>
@@ -174,7 +174,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
{type === EnvelopeType.DOCUMENT ? <Trans>Upload Document</Trans> : <Trans>Upload Template</Trans>}
</h2>
<p className="mt-4 text-base text-muted-foreground">
<p className="mt-4 text-md text-muted-foreground">
<Trans>Drag and drop your document here</Trans>
</p>
@@ -1,38 +1,13 @@
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans } from '@lingui/react/macro';
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useId, useState } from 'react';
import { useState } from 'react';
import { match } from 'ts-pattern';
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 = {
organisationId: string;
monthlyStats: Pick<
@@ -40,151 +15,13 @@ type OrganisationUsagePanelProps = {
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
>[];
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 = ({
organisationId,
monthlyStats,
organisationClaim,
capacityUsage,
}: OrganisationUsagePanelProps) => {
const monthlyUsagePeriodId = useId();
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
@@ -193,105 +30,86 @@ export const OrganisationUsagePanel = ({
// current period), so only offer the reset action when viewing the current month.
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
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[] = [
const rows = [
{
counter: 'document',
counter: 'document' as const,
label: <Trans>Documents</Trans>,
icon: FileIcon,
used: selectedStat?.documentCount ?? 0,
effectiveLimit: organisationClaim.documentQuota,
},
{
counter: 'email',
counter: 'email' as const,
label: <Trans>Emails</Trans>,
icon: MailIcon,
used: selectedStat?.emailCount ?? 0,
effectiveLimit: organisationClaim.emailQuota,
},
{
counter: 'api',
counter: 'api' as const,
label: <Trans>API requests</Trans>,
icon: PlugIcon,
used: selectedStat?.apiCount ?? 0,
effectiveLimit: organisationClaim.apiQuota,
},
];
return (
<div className="mt-4 space-y-6">
{capacityRows.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{capacityRows.map((row) => (
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
))}
</div>
) : null}
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-sm">
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
</h3>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
<Trans>Monthly usage</Trans>
</h3>
{monthlyStats.length > 0 && (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{monthlyStats.length > 0 ? (
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthlyStats.map((stat) => (
<SelectItem key={stat.period} value={stat.period}>
{stat.period}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
{rows.map((row) => {
const percent =
row.effectiveLimit && row.effectiveLimit > 0
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
: 0;
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{monthlyRows.map((row) => (
<UsageStatCard
key={row.counter}
label={row.label}
icon={row.icon}
used={row.used}
limit={row.effectiveLimit}
action={
selectedStat && isCurrentPeriod ? (
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
) : undefined
}
/>
))}
return (
<div key={row.counter} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{row.label}</span>
<span className="text-muted-foreground">
{row.used} /{' '}
{match(row.effectiveLimit)
.with(null, () => <Trans>Unlimited</Trans>)
.with(0, () => <Trans>Blocked</Trans>)
.otherwise(String)}
</span>
</div>
<UsageStatCard
label={<Trans>Reports</Trans>}
icon={MailOpenIcon}
used={selectedStat?.emailReports ?? 0}
limit={null}
countOnly
footnote={<Trans>Sent this period</Trans>}
/>
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
{selectedStat && isCurrentPeriod && (
<div className="flex w-full justify-end pt-1">
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
</div>
)}
</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>
@@ -2,7 +2,6 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { RotateCcwIcon } from 'lucide-react';
import { useRevalidator } from 'react-router';
type OrganisationUsageResetButtonProps = {
@@ -33,7 +32,6 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
loading={isPending}
onClick={() => reset({ organisationId, counter })}
>
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
<Trans>Reset</Trans>
</Button>
);
@@ -1,9 +1,7 @@
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Trans, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
type RateLimitEntryValue = { window: string; max: number };
@@ -13,153 +11,50 @@ type RateLimitArrayInputProps = {
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) => {
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 entries = value ?? [];
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
if (index >= value.length) {
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 next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
onChange(next);
};
const removeEntry = (index: number) => {
if (index >= value.length) {
setDraftEntry(null);
return;
}
const next = value.filter((_, i) => i !== index);
onChange(persistEntries(next));
onChange(entries.filter((_, i) => i !== index));
};
const addEntry = () => {
setDraftEntry(EMPTY_ENTRY);
onChange([...entries, { window: '5m', max: 100 }]);
};
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span className="w-20 shrink-0">
<Trans>Window</Trans>
</span>
<span className="flex-1">
<Trans>Max requests</Trans>
</span>
<span className="w-9 shrink-0" aria-hidden="true" />
</div>
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
className="w-24"
placeholder="5m"
value={entry.window}
disabled={disabled}
onChange={(e) => updateEntry(index, { window: e.target.value })}
/>
<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>
))}
{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}
>
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add rate limit window</Trans>
<Trans>Add rate limit</Trans>
</Button>
</div>
);
@@ -1,9 +1,11 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { DownloadIcon, FolderInputIcon, Trash2Icon, XCircleIcon, XIcon } from 'lucide-react';
import { useEffect } from 'react';
export type EnvelopesTableBulkActionBarProps = {
selectedCount: number;
onDownloadClick?: () => void;
onMoveClick: () => void;
onDeleteClick: () => void;
onCancelClick?: () => void;
@@ -12,6 +14,7 @@ export type EnvelopesTableBulkActionBarProps = {
export const EnvelopesTableBulkActionBar = ({
selectedCount,
onDownloadClick,
onMoveClick,
onDeleteClick,
onCancelClick,
@@ -19,37 +22,103 @@ export const EnvelopesTableBulkActionBar = ({
}: EnvelopesTableBulkActionBarProps) => {
const { t } = useLingui();
useEffect(() => {
if (selectedCount === 0) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClearSelection();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [selectedCount, onClearSelection]);
if (selectedCount === 0) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="font-medium text-sm">
<Trans>{selectedCount} selected</Trans>
</span>
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-1 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-black/10 dark:ring-white/10">
<div className="flex items-center gap-x-2 px-2">
<span className="sr-only" aria-live="polite">
<Trans>{selectedCount} selected</Trans>
</span>
<span
aria-hidden="true"
className="flex h-5 min-w-5 items-center justify-center rounded-md bg-primary px-1 font-semibold text-primary-foreground text-xs tabular-nums"
>
{selectedCount}
</span>
<span aria-hidden="true" className="font-medium text-foreground text-sm max-[420px]:hidden">
<Trans>selected</Trans>
</span>
</div>
<div className="h-6 w-px bg-border" />
<div className="mx-1 h-5 w-px bg-border" />
<Button type="button" variant="outline" size="sm" onClick={onMoveClick}>
<FolderInputIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onMoveClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<FolderInputIcon className="size-4 shrink-0" />
<Trans>Move</Trans>
</Button>
{onDownloadClick && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDownloadClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<DownloadIcon className="size-4 shrink-0" />
<Trans>Download</Trans>
</Button>
)}
{onCancelClick && (
<Button type="button" variant="outline" size="sm" onClick={onCancelClick}>
<XCircleIcon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancelClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2"
>
<XCircleIcon className="size-4 shrink-0" />
<Trans>Cancel</Trans>
</Button>
)}
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onDeleteClick}
className="h-8 gap-x-1.5 py-1.5 pr-2.5 pl-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2Icon className="size-4 shrink-0" />
<Trans>Delete</Trans>
</Button>
<Button variant="ghost" size="sm" onClick={onClearSelection} aria-label={t`Clear selection`}>
<XIcon className="h-4 w-4" />
<div className="mx-1 h-5 w-px bg-border" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClearSelection}
aria-label={t`Clear selection`}
className="h-8 w-8 p-0"
>
<XIcon className="size-4 shrink-0" />
</Button>
</div>
);
@@ -1,4 +1,3 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -30,7 +29,7 @@ export type SettingsSecurityPasskeyTableActionsProps = {
};
const ZUpdatePasskeySchema = z.object({
name: ZNameSchema,
name: z.string(),
});
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
+15 -20
View File
@@ -3,32 +3,27 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { i18n } from '@lingui/core';
import { detect, fromHtmlTag } from '@lingui/detect-locale';
import { I18nProvider } from '@lingui/react';
import { StrictMode, startTransition } from 'react';
import { StrictMode, startTransition, useEffect } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
import './utils/polyfills/promise-with-resolvers';
/**
* Initialised imperatively (not as a component inside `hydrateRoot`) because
* rendering extra client-only siblings changes the React tree structure
* relative to the server render in `entry.server.tsx`. That shifts every
* `useId` value (used by Radix for `id`/`htmlFor`/`aria-*`), causing hydration
* mismatches which can abort hydration entirely when the user interacts with
* the page early, leaving dead event handlers (broken dropdowns, native form
* submits).
*/
function initPosthog() {
function PosthogInit() {
const postHogConfig = extractPostHogConfig();
if (postHogConfig) {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
useEffect(() => {
if (postHogConfig) {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
capture_exceptions: true,
});
});
});
}
}
}, []);
return null;
}
async function main() {
@@ -43,11 +38,11 @@ async function main() {
<I18nProvider i18n={i18n}>
<HydratedRouter />
</I18nProvider>
<PosthogInit />
</StrictMode>,
);
});
void initPosthog();
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
+2 -10
View File
@@ -119,11 +119,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+'));
return (
// `suppressHydrationWarning` because `remix-themes` intentionally mutates
// `data-theme`/`class` on <html> before hydration (PreventFlashOnWrongTheme),
// so the server-rendered attributes never match the client render when the
// theme is resolved from the system preference. Attribute-only, one level deep.
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''} suppressHydrationWarning>
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
<head>
<meta charSet="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@@ -177,11 +173,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<script
nonce={nonce(cspNonce)}
dangerouslySetInnerHTML={{
// `__webpack_nonce__` is read by `get-nonce` (used by
// react-remove-scroll / react-style-singleton inside Radix menus and
// dialogs) to stamp runtime-injected <style> elements. Without it the
// strict `style-src-elem` CSP blocks the scroll-lock styles.
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}; window.__webpack_nonce__ = ${JSON.stringify(cspNonce ?? '')}`,
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
}}
/>
@@ -8,7 +8,6 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
import { trpc } from '@documenso/trpc/react';
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 { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -31,7 +30,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client';
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
@@ -43,6 +42,7 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-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 { ClaimLimitFields } from '~/components/general/claim-limit-fields';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -268,32 +268,54 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<GenericOrganisationAdminForm organisation={organisation} />
<SettingsHeader
title={t`Organisation usage`}
subtitle={t`Current usage against organisation limits.`}
className="mt-6"
hideDivider
/>
<div className="mt-6 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-sm">
<Trans>Organisation usage</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Current usage against organisation limits.</Trans>
</p>
</div>
</div>
<OrganisationUsagePanel
organisationId={organisation.id}
monthlyStats={organisation.monthlyStats}
organisationClaim={organisation.organisationClaim}
capacityUsage={{
members: organisation.members.length,
teams: organisation.teams.length,
}}
/>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<DetailsCard label={<Trans>Members</Trans>}>
<DetailsValue>
{organisation.members.length} /{' '}
{organisation.organisationClaim.memberCount === 0
? t`Unlimited`
: organisation.organisationClaim.memberCount}
</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">
<Accordion type="single" collapsible>
<AccordionItem value="global-settings" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<p className="font-semibold text-base">
<p className="font-medium text-sm">
<Trans>Global Settings</Trans>
</p>
<p className="mt-1 text-muted-foreground text-sm">
<p className="mt-1 font-normal text-muted-foreground text-sm">
<Trans>Default settings applied to this organisation.</Trans>
</p>
</div>
@@ -313,15 +335,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
className="mt-16"
/>
<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"
>
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Subscription</Trans>
@@ -329,12 +343,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AlertDescription className="mr-2">
{organisation.subscription ? (
<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>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
) : (
<span>
<Trans>No subscription found</Trans>
@@ -347,7 +356,6 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div>
<Button
variant="outline"
className="bg-background"
loading={isCreatingStripeCustomer}
onClick={async () => createStripeCustomer({ organisationId })}
>
@@ -358,7 +366,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
{organisation.customerId && !organisation.subscription && (
<div>
<Button variant="outline" className="bg-background" asChild>
<Button variant="outline" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
@@ -375,13 +383,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<AdminOrganisationSyncSubscriptionDialog
organisationId={organisationId}
trigger={
<Button variant="outline" className="bg-background">
<Button variant="outline">
<Trans>Sync Stripe subscription</Trans>
</Button>
}
/>
<Button variant="outline" className="bg-background" asChild>
<Button variant="outline" asChild>
<Link
target="_blank"
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
@@ -398,27 +406,21 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
<div className="mt-16 space-y-10">
<div>
<h3 className="font-semibold text-base">
<label className="font-medium text-sm leading-none">
<Trans>Organisation Members</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>People with access to this organisation.</Trans>
</p>
</label>
<div className="mt-3">
<div className="my-2">
<DataTable columns={organisationMembersColumns} data={organisation.members} />
</div>
</div>
<div>
<h3 className="font-semibold text-base">
<label className="font-medium text-sm leading-none">
<Trans>Organisation Teams</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Teams that belong to this organisation.</Trans>
</p>
</label>
<div className="mt-3">
<div className="my-2">
<DataTable columns={teamsColumns} data={organisation.teams} />
</div>
</div>
@@ -646,7 +648,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
<FormLabel className="flex items-center">
<Trans>Inherited subscription claim</Trans>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
@@ -679,15 +681,10 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
</TooltipContent>
</Tooltip>
</FormLabel>
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
{field.value ? (
<span className="font-mono text-foreground">{field.value}</span>
) : (
<span className="text-muted-foreground">
<Trans>No inherited claim</Trans>
</span>
)}
</div>
<FormControl>
<Input disabled {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@@ -718,113 +715,108 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.teamCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Team Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.memberCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Member Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Number of members allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.envelopeItemCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Envelope Item Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="claims.recipientCount"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Recipient Count</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
/>
</FormControl>
<FormDescription>
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<h3 className="font-semibold text-base">
<FormLabel>
<Trans>Feature Flags</Trans>
</h3>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>Capabilities enabled for this organisation.</Trans>
</p>
</FormLabel>
<div className="mt-3 space-y-2 rounded-md border p-4">
<div className="mt-2 space-y-2 rounded-md border p-4">
{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
@@ -287,11 +287,7 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminGlobalSettingsSection
settings={team.teamGlobalSettings}
inheritedSettings={team.organisation.organisationGlobalSettings}
isTeam
/>
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
</div>
</AccordionContent>
</AccordionItem>
@@ -1,6 +1,7 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
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 type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -48,29 +49,26 @@ export default function OrganisationSettingsBrandingPage() {
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
let uploadedBrandingLogo: string | undefined;
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateOrganisationBrandingLogo(formData);
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
const result = await updateOrganisationSettings({
organisationId: organisation.id,
data: {
brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -88,7 +88,7 @@ export default function OrganisationSettingsDocumentPage() {
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership,
delegateDocumentOwnership: delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
@@ -3,7 +3,6 @@ import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/org
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZNameSchema } from '@documenso/lib/types/name';
import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -29,6 +28,7 @@ import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { z } from 'zod';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import {
@@ -36,6 +36,7 @@ import {
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
@@ -112,7 +113,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
}
const ZUpdateOrganisationGroupFormSchema = z.object({
name: ZNameSchema,
name: z.string().min(1, msg`Name is required`.id),
organisationRole: z.nativeEnum(OrganisationMemberRole),
memberIds: z.array(z.string()),
});
@@ -14,13 +14,19 @@ import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import {
EnvelopeType,
FolderType,
OrganisationType,
type DocumentStatus as PrismaDocumentStatus,
} from '@prisma/client';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkDownloadDialog } from '~/components/dialogs/envelopes-bulk-download-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -62,8 +68,12 @@ export default function DocumentsPage() {
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
const [envelopeMetaCache, setEnvelopeMetaCache] = useSessionStorage<
Record<string, { title: string; status: PrismaDocumentStatus }>
>('documents-bulk-selection-meta', {});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isBulkDownloadDialogOpen, setIsBulkDownloadDialogOpen] = useState(false);
const [isBulkCancelDialogOpen, setIsBulkCancelDialogOpen] = useState(false);
const selectedEnvelopeIds = useMemo(() => {
@@ -95,6 +105,38 @@ export default function DocumentsPage() {
},
);
useEffect(() => {
setEnvelopeMetaCache((prev) => {
const next: Record<string, { title: string; status: PrismaDocumentStatus }> = {};
for (const id of Object.keys(prev)) {
if (rowSelection[id]) {
next[id] = prev[id];
}
}
for (const document of data?.data ?? []) {
if (rowSelection[document.envelopeId]) {
next[document.envelopeId] = {
title: document.title,
status: document.status,
};
}
}
return next;
});
}, [data?.data, rowSelection, setEnvelopeMetaCache]);
const selectedEnvelopesForDownload = useMemo(() => {
return selectedEnvelopeIds
.map((id) => {
const meta = envelopeMetaCache[id];
return meta ? { id, title: meta.title, status: meta.status } : null;
})
.filter((item): item is { id: string; title: string; status: PrismaDocumentStatus } => item !== null);
}, [selectedEnvelopeIds, envelopeMetaCache]);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
@@ -235,12 +277,28 @@ export default function DocumentsPage() {
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onDownloadClick={() => setIsBulkDownloadDialogOpen(true)}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onCancelClick={() => setIsBulkCancelDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkDownloadDialog
envelopes={selectedEnvelopesForDownload}
open={isBulkDownloadDialogOpen}
onOpenChange={setIsBulkDownloadDialogOpen}
onSuccess={(successfulEnvelopeIds) => {
setRowSelection((prev) => {
const next = { ...prev };
for (const id of successfulEnvelopeIds) {
delete next[id];
}
return next;
});
}}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.DOCUMENT}
@@ -1,5 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
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 type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -37,7 +38,6 @@ export default function TeamsSettingsPage() {
});
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();
@@ -48,23 +48,22 @@ export default function TeamsSettingsPage() {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
let uploadedBrandingLogo: string | undefined;
formData.append('payload', JSON.stringify({ teamId: team.id }));
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateTeamBrandingLogo(formData);
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
const result = await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors,
@@ -82,7 +82,7 @@ export default function TeamsSettingsPage() {
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}),
delegateDocumentOwnership,
delegateDocumentOwnership: delegateDocumentOwnership,
},
});
@@ -1,18 +1,14 @@
import { trpc } from '@documenso/trpc/react';
import type { TGetApiTokensResponse } from '@documenso/trpc/server/api-token-router/get-api-tokens.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { useMemo } from 'react';
import { DateTime } from 'luxon';
import { TokenCreateDialog } from '~/components/dialogs/token-create-dialog';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -22,88 +18,33 @@ export function meta() {
}
export default function ApiTokensPage() {
const { t, i18n } = useLingui();
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getMany.useQuery();
const team = useOptionalCurrentTeam();
const isUnauthorized = !!team && team.currentTeamRole !== TeamMemberRole.ADMIN;
const {
data: tokens,
isLoading,
isError,
} = trpc.apiToken.getMany.useQuery(undefined, {
enabled: !isUnauthorized,
});
const columns = useMemo(() => {
return [
{
header: t`Name`,
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.name}</span>,
},
{
header: t`Created`,
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: t`Expires`,
cell: ({ row }) => {
if (!row.original.expires) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
if (row.original.expires < new Date()) {
return (
<Badge variant="destructive" size="small">
<Trans>Expired</Trans>
</Badge>
);
}
return i18n.date(row.original.expires);
},
},
{
header: t`Actions`,
cell: ({ row }) => (
<TokenDeleteDialog token={row.original}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
),
},
] satisfies DataTableColumnDef<TGetApiTokensResponse[number]>[];
}, []);
return (
<div>
<SettingsHeader
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
Create and manage API tokens. See our{' '}
On this page, you can create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
rel="noopener"
>
documentation
Documentation
</a>{' '}
for more information.
</Trans>
}
>
{!isUnauthorized && <TokenCreateDialog />}
</SettingsHeader>
/>
{isUnauthorized ? (
{team && team?.currentTeamRole !== TeamMemberRole.ADMIN ? (
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="warning">
<div>
<AlertTitle>
@@ -115,43 +56,58 @@ export default function ApiTokensPage() {
</div>
</Alert>
) : (
<DataTable
columns={columns}
data={tokens ?? []}
perPage={0}
currentPage={0}
totalPages={0}
error={{
enable: isError,
}}
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
<Trans>You have no API tokens yet. Your tokens will be shown here once you create them.</Trans>
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="mt-8 mb-4" />
<h4 className="font-medium text-xl">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="mt-2 text-muted-foreground text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
/>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="mt-2 text-muted-foreground text-xs">
<Trans>Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
{token.expires ? (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}</Trans>
</p>
) : (
<p className="mt-1 text-muted-foreground text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
@@ -7,7 +7,6 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { Plural } from '@lingui/react/macro';
import { UsersIcon } from 'lucide-react';
@@ -15,7 +14,6 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
@@ -72,18 +70,8 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
};
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields);
if (recipientsWithMissingFields.length > 0) {
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: true,
} as const;
}
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: false,
template: {
...template,
folder: null,
@@ -108,7 +96,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
.then((envelopeForSigning) => {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: false,
envelopeForSigning,
} as const;
})
@@ -121,13 +108,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
} as const;
}
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: true,
} as const;
}
throw new Response('Not Found', { status: 404 });
});
};
@@ -201,10 +181,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DirectTemplateAuthPageView />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { template, directTemplateRecipient } = data;
return (
@@ -259,10 +235,6 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
@@ -82,7 +82,7 @@ export default function WaitingForTurnToSignPage({ loaderData }: Route.Component
<RecipientBranding branding={branding} cspNonce={cspNonce} />
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md text-center">
<h2 className="font-bold text-3xl tracking-tight">
<h2 className="font-bold text-3xl tracking-tigh">
<Trans>Waiting for Your Turn</Trans>
</h2>
@@ -32,10 +32,6 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
}))
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
@@ -81,10 +77,6 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
title: msg`Error`,
description: msg`The document was created but could not be sent to recipients.`,
}))
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
}))
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
title: msg`Error`,
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
+6 -6
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.18.1",
"@react-router/serve": "^7.18.1",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@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.18.1",
"@react-router/remix-routes-option-adapter": "^7.18.1",
"@react-router/dev": "^7.12.0",
"@react-router/remix-routes-option-adapter": "^7.12.0",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^28.0.9",
"@rollup/plugin-json": "^6.1.0",
@@ -100,11 +100,11 @@
"esbuild": "^0.27.0",
"remix-flat-routes": "^0.8.5",
"rollup": "^4.53.3",
"tsx": "^4.23.1",
"tsx": "^4.20.6",
"typescript": "5.6.2",
"vite": "^7.2.4",
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.16.0"
"version": "2.14.0"
}
+28 -1
View File
@@ -1,8 +1,9 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
@@ -11,11 +12,14 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
@@ -57,6 +61,29 @@ export const filesRoute = new Hono<HonoEnv>()
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(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
@@ -13,6 +13,27 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
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({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
+1
View File
@@ -105,6 +105,7 @@ app.route('/api/auth', auth);
// Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute);
// AI route.
+2 -2
View File
@@ -19,7 +19,7 @@ WORKDIR /app
COPY . .
RUN npm install -g "turbo@^2.10.0"
RUN npm install -g "turbo@^1.9.3"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
@@ -79,7 +79,7 @@ COPY --from=builder /app/out/full/ .
# Finally copy the turbo.json file so that we can run turbo commands
COPY turbo.json turbo.json
RUN npm install -g "turbo@^2.10.0"
RUN npm install -g "turbo@^1.9.3"
RUN turbo run build --filter=@documenso/remix...
+2015 -4838
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.16.0",
"version": "2.14.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -61,13 +61,12 @@
"@ts-rest/serverless": "^3.52.1",
"dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.27.0",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.17.9",
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^9.0.0",
"nodemailer": "^8.0.5",
"pdfjs-dist": "5.4.296",
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
@@ -79,7 +78,7 @@
"rimraf": "^6.1.2",
"superjson": "^2.2.5",
"syncpack": "^14.0.0-alpha.27",
"turbo": "^2.10.0",
"turbo": "^1.13.4",
"vite": "^7.2.4",
"vite-plugin-static-copy": "^3.1.4",
"zod-openapi": "^4.2.4",
@@ -105,7 +104,6 @@
"overrides": {
"lodash": "4.18.1",
"pdfjs-dist": "5.4.296",
"postcss": "^8.5.19",
"typescript": "5.6.2",
"zod": "$zod",
"fumadocs-mdx": {
@@ -44,6 +44,46 @@ test.describe('File upload endpoint authorization', () => {
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 }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
@@ -1,37 +0,0 @@
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();
});
});
@@ -1,225 +0,0 @@
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();
});
@@ -3,7 +3,7 @@ import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { type Download, expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
@@ -50,10 +50,10 @@ test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => {
@@ -67,7 +67,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
await expect(page.getByText(new RegExp(`${documents.length}\\s*selected`))).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -80,11 +80,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => {
@@ -98,13 +98,13 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -113,6 +113,43 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible();
});
test('[BULK_ACTIONS]: can bulk download multiple documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Download Documents')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 1')).toBeVisible();
await expect(dialog.getByText('Bulk Test Doc 2')).toBeVisible();
await expect(dialog.getByText('Draft').first()).toBeVisible();
const downloads: Download[] = [];
page.on('download', (d) => downloads.push(d));
await dialog.getByRole('button', { name: 'Download' }).click();
await expect.poll(() => downloads.length, { timeout: 10_000 }).toBe(2);
expect(downloads.map((d) => d.suggestedFilename())).toEqual(
expect.arrayContaining(['Bulk Test Doc 1.pdf', 'Bulk Test Doc 2.pdf']),
);
await expectToastTextToBeVisible(page, 'Documents downloaded');
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => {
const { sender } = await seedBulkActionsTestRequirements();
@@ -152,14 +189,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -172,13 +209,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Documents deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -199,7 +236,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -236,14 +273,14 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -1,76 +0,0 @@
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor';
const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template';
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
/**
* Seed a V2 direct template and open it in the native template editor.
*
* Only the native template editor is covered here: direct links only exist
* for templates and are not part of the embedded editor surfaces.
*/
const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: `E2E Direct Template Validation ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: options.createDirectRecipientSignatureField,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
return { user, team, template };
};
test.describe('template editor', () => {
test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
await expect(page.getByText('are missing a signature field')).toBeVisible();
});
test('does not show the warning when all signers have signature fields', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true });
// Wait for the editor to render before asserting the banner is absent.
await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible();
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible();
});
test('warning disappears after placing a signature field', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
// Place a signature field for the direct recipient (auto-selected single recipient).
await clickEnvelopeEditorStep(page, 'addFields');
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 });
// The banner clears once the field is autosaved and the envelope state updates.
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 });
});
});
@@ -1,199 +0,0 @@
import { prisma } from '@documenso/prisma';
import { expect, type Page, test } from '@playwright/test';
import {
clickAddSignerButton,
clickEnvelopeEditorStep,
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
setRecipientEmail,
setRecipientName,
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
/**
* Reproduction for the recipient autosave race condition.
*
* Symptom (production only, where there is real network lag):
* 1. The author adds a recipient and types its name/email.
* 2. They navigate to the "Add Fields" step.
* 3. The recipient selector shows the default "Recipient 1" placeholder
* instead of the recipient they just typed, and the typed name/email is
* silently lost.
*
* Theory (see packages/lib/client-only/hooks/use-envelope-autosave.ts):
* When the author navigates, `flushAutosave()` is awaited before the Add
* Fields page renders. If an *earlier* (empty) recipient save is still
* in-flight at that moment, `flush()` awaits that in-flight save and returns
* WITHOUT committing the newer typed data sitting in `lastArgsRef` (whose
* debounce timer it just cleared). The typed data is dropped, the empty
* recipient persists, and the selector renders "Recipient 1".
*
* This only happens when a save is still in-flight at navigation time, which is
* why it never reproduces locally (fast saves) but does on a laggy network.
*
* The test below simulates that lag by holding the first `envelope.recipient.set`
* request open. It asserts the CORRECT behaviour (typed recipient survives), so
* it is RED while the bug exists and GREEN once the autosave hook is fixed.
*/
const RECIPIENT_SET_PROCEDURE = 'envelope.recipient.set';
// How long to hold the first recipient autosave "in-flight" to emulate prod lag.
const SIMULATED_NETWORK_LAG_MS = 5000;
const FIRST_RECIPIENT = {
name: 'Alice Author',
email: 'alice-autosave-race@example.com',
};
const SECOND_RECIPIENT = {
name: 'Bob Builder',
email: 'bob-autosave-race@example.com',
};
type RecipientSetLagHandle = {
/** Resolves the instant the first recipient.set request is in-flight on the client. */
firstRecipientSetInFlight: Promise<void>;
/** Raw request bodies of every recipient.set call we intercepted. */
recipientSetRequestBodies: string[];
};
/**
* Installs a fake "production network lag" on the recipient autosave mutation.
*
* Only the FIRST recipient.set request is held open for `lagMs` (this is the save
* that must still be in-flight at navigation time for the race to occur). It
* resolves `firstRecipientSetInFlight` the instant it is intercepted so the test
* can keep typing while that save is pending. Subsequent recipient.set requests
* (e.g. the follow-up save the fixed hook issues) are forwarded immediately so the
* test does not pay the lag twice.
*/
const installRecipientSetLag = async (page: Page, lagMs: number): Promise<RecipientSetLagHandle> => {
let markFirstInFlight: () => void = () => {};
const firstRecipientSetInFlight = new Promise<void>((resolve) => {
markFirstInFlight = resolve;
});
const recipientSetRequestBodies: string[] = [];
await page.route('**/api/trpc/**', async (route) => {
const request = route.request();
if (request.method() !== 'POST' || !request.url().includes(RECIPIENT_SET_PROCEDURE)) {
await route.continue();
return;
}
const callIndex = recipientSetRequestBodies.length + 1;
recipientSetRequestBodies.push(request.postData() ?? '');
if (callIndex === 1) {
// eslint-disable-next-line no-console
console.log(`[test] holding first ${RECIPIENT_SET_PROCEDURE} for ${lagMs}ms (simulated network lag)`);
// The empty save is now in-flight from the client's perspective.
markFirstInFlight();
await new Promise((resolve) => setTimeout(resolve, lagMs));
} else {
// eslint-disable-next-line no-console
console.log(`[test] forwarding ${RECIPIENT_SET_PROCEDURE} #${callIndex} (no lag)`);
}
await route.continue();
});
return { firstRecipientSetInFlight, recipientSetRequestBodies };
};
const assertEnvelopeRecipientsPersisted = async (surface: TEnvelopeEditorSurface) => {
if (!surface.envelopeId) {
throw new Error('Expected the document editor surface to have an envelopeId');
}
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: surface.envelopeId },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
const persistedEmails = envelope.recipients.map((recipient) => recipient.email).filter(Boolean);
// eslint-disable-next-line no-console
console.log(
'[test] persisted recipients:',
JSON.stringify(
envelope.recipients.map((recipient) => ({ name: recipient.name, email: recipient.email })),
null,
2,
),
);
expect(persistedEmails).toContain(FIRST_RECIPIENT.email);
expect(persistedEmails).toContain(SECOND_RECIPIENT.email);
};
test.describe('envelope editor recipient autosave race (network lag)', () => {
test('document editor: typed recipient survives navigation to Add Fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const { firstRecipientSetInFlight, recipientSetRequestBodies } = await installRecipientSetLag(
page,
SIMULATED_NETWORK_LAG_MS,
);
// 1. Add a second signer row. A blank document already has one empty default
// signer, so this schedules an autosave of TWO empty recipients
// (name='' / email='') - this is the save that will be in-flight.
await clickAddSignerButton(surface.root);
await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2);
// 2. Wait until that empty autosave is actually in-flight on the client. This
// is the precondition the bug needs: a slow save holding the autosave lock.
await firstRecipientSetInFlight;
// 3. The author now fills in the recipients they are adding.
await setRecipientName(surface.root, 0, FIRST_RECIPIENT.name);
await setRecipientEmail(surface.root, 0, FIRST_RECIPIENT.email);
await setRecipientName(surface.root, 1, SECOND_RECIPIENT.name);
await setRecipientEmail(surface.root, 1, SECOND_RECIPIENT.email);
// 4. Immediately navigate to Add Fields (before the typed data's debounce
// fires). flushAutosave() awaits the in-flight EMPTY save; with the bug
// present it returns without ever committing the typed data.
await clickEnvelopeEditorStep(surface.root, 'addFields');
// 5. Wait for the Add Fields page to render (after the lagged flush resolves).
await expect(surface.root.getByText('Selected Recipient')).toBeVisible({
timeout: SIMULATED_NETWORK_LAG_MS + 15000,
});
// Diagnostics - the request bodies show what actually reached the server.
// Buggy: only the first (empty) save is ever sent. Fixed: a follow-up save
// carrying the typed recipients is sent too.
// eslint-disable-next-line no-console
console.log('\n===== AUTOSAVE RACE DIAGNOSTICS =====');
// eslint-disable-next-line no-console
console.log(`recipient.set requests sent to server: ${recipientSetRequestBodies.length}`);
// eslint-disable-next-line no-console
console.log(
`server ever received "${FIRST_RECIPIENT.email}": ${recipientSetRequestBodies.some((body) => body.includes(FIRST_RECIPIENT.email))}`,
);
// eslint-disable-next-line no-console
console.log('=====================================\n');
// 6. THE USER-VISIBLE BUG: the selected recipient must be the one we typed
// (Alice), not the default "Recipient 1" placeholder.
const selectedRecipientSection = surface.root.locator('section').filter({ hasText: 'Selected Recipient' });
await expect(selectedRecipientSection.getByRole('combobox')).toContainText(FIRST_RECIPIENT.name);
// 7. THE DATA LOSS: the typed recipients must actually be persisted.
await assertEnvelopeRecipientsPersisted(surface);
});
});
@@ -6,7 +6,6 @@ import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { signSignaturePad } from '../fixtures/signature';
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
const { user, team } = await seedUser();
@@ -74,19 +73,8 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
await expect(page.locator('body')).toContainText('public-direct-template-title');
await expect(page.locator('body')).toContainText('public-direct-template-description');
const directSignatureField = directTemplate.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await page.getByRole('link', { name: 'Sign' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
@@ -142,38 +142,3 @@ 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.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();
});
@@ -49,10 +49,10 @@ test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ p
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await expect(page.getByText('2 selected')).toBeVisible();
await expect(page.getByText(/2\s*selected/)).toBeVisible();
});
test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => {
@@ -66,7 +66,7 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
await expect(page.getByText(new RegExp(`${templates.length}\\s*selected`))).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
@@ -79,11 +79,11 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
});
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).toBeVisible();
await page.getByLabel('Clear selection').click();
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => {
@@ -97,13 +97,13 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -151,14 +151,14 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await page.getByRole('button', { name: folder.name }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => {
@@ -171,13 +171,13 @@ test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }
});
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
await expectToastTextToBeVisible(page, 'Templates deleted');
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
await expect(page.getByText(/\d+\s*selected/)).not.toBeVisible();
});
test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => {
@@ -199,7 +199,7 @@ test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) =
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('button', { name: folder.name })).toBeVisible();
@@ -236,14 +236,14 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click();
await expect(page.getByText('1 selected')).toBeVisible();
await expect(page.getByText(/1\s*selected/)).toBeVisible();
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Move', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Move' }).click();
await expectToastTextToBeVisible(page, 'Selected items have been moved.');
@@ -197,18 +197,7 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled();
const directSignatureField = directTemplateWithAuth.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await page.getByRole('button', { name: 'Continue' }).click();
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
@@ -246,37 +235,6 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
const directSignatureField = directTemplateWithAuth.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
// Wait for the PDF and the Konva canvas overlay to be ready.
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
await signSignaturePad(page);
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x =
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
@@ -308,16 +266,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
const directSignatureField = template.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
@@ -351,13 +299,19 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
},
});
// The seeded direct template already includes a signature field for the direct recipient.
const directSignatureField = template.fields[0];
const directTemplateRecipient = template.recipients[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
@@ -459,13 +413,19 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
},
});
// The seeded direct template already includes a signature field for the direct recipient.
const directSignatureField = template.fields[0];
const directTemplateRecipient = template.recipients[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
@@ -561,48 +521,3 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
});
test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({
page,
}) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: 'V1 invalid direct template',
userId: user.id,
teamId: team.id,
createDirectRecipientSignatureField: false,
});
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
// The signing flow must not render.
await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible();
});
test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({
page,
}) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: 'V2 invalid direct template',
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: false,
});
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
// The signing flow (PDF canvas) must not render.
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible();
});
@@ -1,101 +0,0 @@
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: options.envelopeId },
});
return await prisma.field.create({
data: {
envelopeId: options.envelopeId,
envelopeItemId: envelopeItem.id,
recipientId: options.recipientId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 10,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
},
});
};
test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({
page,
}) => {
const { user, team } = await seedUser();
// seedTemplate creates one SIGNER recipient and no fields.
await seedTemplate({
title: 'Template missing signature fields',
userId: user.id,
teamId: team.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
// Enable distribution so the document is sent on creation.
await page.locator('#distributeDocument').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await expectToastTextToBeVisible(page, 'Missing signature fields');
await expectToastTextToBeVisible(
page,
'The document could not be sent because some signers do not have a signature field',
);
});
test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedTemplate({
title: 'Template with signature fields',
userId: user.id,
teamId: team.id,
});
await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: template.recipients[0].id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
await page.locator('#distributeDocument').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
const envelopeId = page.url().split('/').pop()?.split('?')[0];
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: envelopeId },
});
expect(envelope.status).toBe(DocumentStatus.PENDING);
});
+1 -1
View File
@@ -18,7 +18,7 @@
"@playwright/test": "1.56.1",
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
"tsx": "^4.23.1",
"tsx": "^4.20.6",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0"
},
+1 -1
View File
@@ -1,4 +1,4 @@
import { ZNameSchema } from '@documenso/lib/types/name';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
+3 -3
View File
@@ -17,7 +17,7 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@documenso/nodemailer-resend": "5.0.0",
"@documenso/nodemailer-resend": "4.0.0",
"@documenso/tailwind-config": "*",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
@@ -38,12 +38,12 @@
"@react-email/section": "0.0.16",
"@react-email/tailwind": "^2.0.1",
"@react-email/text": "0.1.5",
"nodemailer": "^9.0.0",
"nodemailer": "^8.0.5",
"react-email": "^5.0.6",
"resend": "^6.5.2"
},
"devDependencies": {
"@documenso/tsconfig": "*",
"@types/nodemailer": "^8.0.1"
"@types/nodemailer": "^8.0.0"
}
}
@@ -87,7 +87,7 @@ export const TemplateDocumentInvite = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-base text-primary-foreground no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sbase no-underline"
href={signDocumentLink}
>
{match(role)
@@ -3,8 +3,6 @@ import type { SentMessageInfo, Transport } from 'nodemailer';
import type { Address } from 'nodemailer/lib/mailer';
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
import { normalizeMailHeaders } from './normalize-headers';
const VERSION = '1.0.0';
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
@@ -56,7 +54,6 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
const [from] = this.toMailChannelsAddresses(mail.data.from);
const [replyTo] = this.toMailChannelsAddresses(mail.data.replyTo);
if (!from) {
return callback(new Error('Missing required field "from"'), null);
@@ -75,8 +72,6 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
headers: requestHeaders,
body: JSON.stringify({
from: from,
reply_to: replyTo,
headers: normalizeMailHeaders(mail.data.headers),
subject: mail.data.subject,
personalizations: [
{
@@ -1,55 +0,0 @@
import type Mail from 'nodemailer/lib/mailer';
/**
* Normalizes nodemailer mail headers into the flat `Record<string, string>`
* shape accepted by HTTP email APIs such as Resend and MailChannels.
*
* Kept in sync with `toResendHeaders` in the `@documenso/nodemailer-resend`
* package, which applies the same normalization for the Resend transport.
*/
export const normalizeMailHeaders = (headers: Mail.Options['headers']): Record<string, string> | undefined => {
if (!headers) {
return undefined;
}
const normalized: Record<string, string> = {};
const appendHeader = (key: string, value: unknown) => {
if (value === null || value === undefined) {
return;
}
const stringValue = String(value);
normalized[key] = normalized[key] ? `${normalized[key]}, ${stringValue}` : stringValue;
};
if (Array.isArray(headers)) {
for (const { key, value } of headers) {
appendHeader(key, value);
}
} else {
for (const [key, value] of Object.entries(headers)) {
if (Array.isArray(value)) {
for (const item of value) {
appendHeader(key, item);
}
continue;
}
if (typeof value === 'object' && value !== null) {
appendHeader(key, value.value);
continue;
}
appendHeader(key, value);
}
}
if (Object.keys(normalized).length === 0) {
return undefined;
}
return normalized;
};
@@ -1,100 +1,84 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Debounced autosave for the envelope editor (recipients, fields, settings).
*
* Only one save runs at a time and the latest edit always wins. If the user
* keeps editing while a save is on the wire, their newest changes get saved
* right after, never dropped.
*/
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// The edit waiting to be saved. Wrapped in an object so null always means "nothing queued".
const pendingRef = useRef<{ value: T } | null>(null);
// The save currently running, if any. Shared so we never kick off two at once.
const commitPromiseRef = useRef<Promise<void> | null>(null);
// saveFn closes over editor state, so keep the latest one around without
// making triggerSave/flush depend on it.
const saveFnRef = useRef(saveFn);
saveFnRef.current = saveFn;
const lastArgsRef = useRef<T | null>(null);
const pendingPromiseRef = useRef<Promise<void> | null>(null);
const [isPending, setIsPending] = useState(false);
const [isCommiting, setIsCommiting] = useState(false);
/**
* Runs saves one at a time until the queue is empty. Anything queued
* mid-save gets picked up on the next loop.
*/
const commit = useCallback((): Promise<void> => {
if (commitPromiseRef.current) {
return commitPromiseRef.current;
}
if (!pendingRef.current) {
return Promise.resolve();
}
const pump = (async () => {
try {
setIsCommiting(true);
while (pendingRef.current) {
const { value } = pendingRef.current;
pendingRef.current = null;
await saveFnRef.current(value);
}
} finally {
// eslint-disable-next-line require-atomic-updates
commitPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
})();
commitPromiseRef.current = pump;
return pump;
}, []);
const triggerSave = useCallback(
(data: T) => {
pendingRef.current = { value: data };
lastArgsRef.current = data;
// A debounce or promise means something is pending
setIsPending(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
timeoutRef.current = setTimeout(async () => {
if (!lastArgsRef.current) {
return;
}
const args = lastArgsRef.current;
lastArgsRef.current = null;
timeoutRef.current = null;
void commit();
setIsCommiting(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
}, delay);
},
[commit, delay],
[saveFn, delay],
);
/**
* Skip the debounce and save now. The editor calls this when it needs
* everything persisted, e.g. before sending or switching steps.
*/
const flush = useCallback(async () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
await commit();
}, [commit]);
if (pendingPromiseRef.current) {
// Already running → wait for it
await pendingPromiseRef.current;
return;
}
if (lastArgsRef.current) {
const args = lastArgsRef.current;
lastArgsRef.current = null;
setIsCommiting(true);
setIsPending(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
}
}, [saveFn]);
// Last-ditch attempt to save if the tab closes with unsaved edits.
useEffect(() => {
const handleBeforeUnload = () => {
if (timeoutRef.current || pendingRef.current || commitPromiseRef.current) {
if (timeoutRef.current || pendingPromiseRef.current) {
void flush();
}
};
+15
View File
@@ -1,10 +1,25 @@
import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
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> = {
DOCUMENSO: 'Documenso',
GOOGLE: 'Google',
-10
View File
@@ -9,13 +9,3 @@
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/
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'];
-9
View File
@@ -36,13 +36,6 @@ export enum AppErrorCode {
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
* signer has no signature field.
*/
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
/**
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
* for the recovery taxonomy.
@@ -91,7 +84,6 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -299,7 +291,6 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
+2 -2
View File
@@ -60,8 +60,8 @@
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
"postcss": "^8.5.19",
"postcss-selector-parser": "^7.1.4",
"postcss": "^8.5.14",
"postcss-selector-parser": "^7.1.1",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
@@ -1,26 +0,0 @@
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);
};
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
.join(', ');
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
});
}
@@ -5,7 +5,6 @@ import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@@ -126,17 +125,6 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
@@ -1,4 +1,4 @@
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
@@ -22,6 +22,39 @@ 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 => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
@@ -1,4 +1,4 @@
import { getQuotaWarningCount } from '../../universal/quota-usage';
export const QUOTA_WARNING_THRESHOLD = 0.8;
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
// warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above.
const warningCount = getQuotaWarningCount(quota);
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
@@ -40,7 +40,6 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { incrementDocumentId } from '../envelope/increment-id';
@@ -173,17 +172,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
recipients,
recipients.flatMap((recipient) => recipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
+43 -84
View File
@@ -1416,8 +1416,8 @@ msgid "Add Placeholders"
msgstr "Platzhalter hinzufügen"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Add rate limit window"
msgstr ""
msgid "Add rate limit"
msgstr "Rate-Limit hinzufügen"
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Add recipients"
@@ -2008,7 +2008,6 @@ msgstr "Jede Quelle"
msgid "Any Status"
msgstr "Jeder Status"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
msgid "API"
msgstr "API"
@@ -2018,6 +2017,10 @@ msgstr "API"
msgid "API key"
msgstr "API-Schlüssel"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "API rate limits"
msgstr "API-Rate-Limits"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "API requests"
msgstr "API-Anfragen"
@@ -2678,10 +2681,6 @@ msgstr "Unterzeichner kann nicht entfernt werden"
msgid "Cannot upload items after the document has been sent"
msgstr "Artikel können nicht hochgeladen werden, nachdem das Dokument versendet wurde."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Capabilities enabled for this organisation."
msgstr ""
#: packages/lib/constants/recipient-roles.ts
msgctxt "Recipient role name"
msgid "Cc"
@@ -4408,6 +4407,10 @@ msgstr "Dokumenteinstellungen"
msgid "Document preferences updated"
msgstr "Dokumentpräferenzen aktualisiert"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Document rate limits"
msgstr "Dokumenten-Rate-Limits"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
msgid "Document rejected"
@@ -4543,7 +4546,6 @@ msgstr "Dokumentation"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-desktop.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx
@@ -4993,6 +4995,10 @@ msgstr "E-Mail-Präferenzen"
msgid "Email preferences updated"
msgstr "E-Mail-Präferenzen aktualisiert"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Email rate limits"
msgstr "E-Mail-Rate-Limits"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Email recipients when a pending document is deleted"
msgstr "Empfänger per E-Mail benachrichtigen, wenn ein ausstehendes Dokument gelöscht wird"
@@ -5088,7 +5094,6 @@ msgstr "E-Mail-Verifizierung wurde entfernt"
msgid "Email verification has been resent"
msgstr "E-Mail-Verifizierung wurde erneut gesendet"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-stats-table.tsx
#: apps/remix/app/components/tables/organisation-email-domains-table.tsx
@@ -5102,14 +5107,16 @@ msgstr "E-Mails"
msgid "Embedding, 5 members included and more"
msgstr "Einbettung, 5 Mitglieder enthalten und mehr"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty = Unlimited, 0 = Blocked"
msgstr "Leer = Unbegrenzt, 0 = Blockiert"
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Empty field"
msgstr "Leeres Feld"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h."
msgstr ""
#: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx
msgid "Enable"
msgstr "Aktivieren"
@@ -5229,10 +5236,6 @@ msgstr "Stellen Sie sicher, dass Sie das Embedding-Token verwenden und nicht das
msgid "Enter"
msgstr "Eingeben"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a max request count greater than 0"
msgstr ""
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
msgid "Enter a name for your new folder. Folders help you organise your items."
msgstr "Geben Sie einen Namen für Ihren neuen Ordner ein. Ordner helfen Ihnen, Ihre Dateien zu organisieren."
@@ -5241,10 +5244,6 @@ msgstr "Geben Sie einen Namen für Ihren neuen Ordner ein. Ordner helfen Ihnen,
msgid "Enter a new title"
msgstr "Neuen Titel eingeben"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Enter a window, e.g. 5m"
msgstr ""
#: apps/remix/app/components/forms/subscription-claim-form.tsx
msgid "Enter claim name"
msgstr "Anspruchsname eingeben"
@@ -5503,10 +5502,6 @@ msgstr "Alle haben unterschrieben"
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Alle haben unterschrieben! Sie erhalten eine Kopie des unterschriebenen Dokuments per E-Mail."
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Exceeded"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
msgstr "Zeitüberschreitung überschritten"
@@ -6770,10 +6765,6 @@ msgstr "Lichtmodus"
msgid "Like to have your own public profile with agreements?"
msgstr "Möchten Sie Ihr eigenes öffentliches Profil mit Vereinbarungen haben?"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Limit reached"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Limits"
msgstr "Limits"
@@ -7079,10 +7070,6 @@ msgstr "MAU (angemeldet)"
msgid "Max"
msgstr "Max"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Max requests"
msgstr ""
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults."
msgstr "Maximale Dateigröße: 4MB. Maximal 100 Zeilen pro Upload. Leere Werte verwenden die Vorlagenstandards."
@@ -7129,12 +7116,12 @@ msgstr "Mitglied seit"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-groups-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
@@ -7203,12 +7190,16 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp
msgstr "Monatlich aktive Benutzer: Benutzer, die mindestens eines ihrer Dokumente abgeschlossen haben"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly quota"
msgstr ""
msgid "Monthly API quota"
msgstr "Monatliches API-Kontingent"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Monthly usage"
msgstr ""
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly document quota"
msgstr "Monatliches Dokumentenkontingent"
#: apps/remix/app/components/general/claim-limit-fields.tsx
msgid "Monthly email quota"
msgstr "Monatliches E-Mail-Kontingent"
#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx
#: apps/remix/app/components/dialogs/folder-move-dialog.tsx
@@ -7299,6 +7290,7 @@ msgid "Name"
msgstr "Name"
#: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Name is required"
msgstr "Name ist erforderlich"
@@ -7306,10 +7298,6 @@ msgstr "Name ist erforderlich"
msgid "Name Settings"
msgstr "Einstellungen für Namen"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Near limit"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Need to sign documents?"
msgstr "Müssen Dokumente signieren?"
@@ -7449,10 +7437,6 @@ msgstr "Es sind derzeit keine weiteren Maßnahmen Ihrerseits erforderlich."
msgid "No groups found"
msgstr "Keine Gruppen gefunden"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "No inherited claim"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Keine Lizenz konfiguriert"
@@ -8169,10 +8153,6 @@ msgstr "Ausstehende Organisations­einladungen"
msgid "Pending since"
msgstr "Ausstehend seit"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "People with access to this organisation."
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "per month"
@@ -8335,6 +8315,10 @@ msgstr "Bitte geben Sie einen aussagekräftigen Namen für Ihr Token ein. Dies w
msgid "Please enter a number"
msgstr "Bitte gib eine Zahl ein"
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Bitte geben Sie einen gültigen Namen ein."
#: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx
msgid "Please enter a valid number"
msgstr "Bitte geben Sie eine gültige Nummer ein."
@@ -9073,10 +9057,6 @@ msgstr "Organisationsmitglied entfernen"
msgid "Remove Organisation Member"
msgstr "Organisationsmitglied entfernen"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Remove rate limit"
msgstr ""
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
msgid "Remove recipient"
msgstr "Empfänger entfernen"
@@ -9267,10 +9247,6 @@ msgstr "Zahlung klären"
msgid "Resolve payment"
msgstr "Zahlung klären"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Resource blocked"
msgstr ""
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Response"
msgstr "Antwort"
@@ -9847,10 +9823,6 @@ msgstr "Senden..."
msgid "Sent"
msgstr "Gesendet"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Sent this period"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sitzung widerrufen"
@@ -10833,10 +10805,10 @@ msgid "Team URL"
msgstr "Team-URL"
#: apps/remix/app/components/general/org-menu-switcher.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/dashboard.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -10847,10 +10819,6 @@ msgstr "Teams"
msgid "Teams help you organise your work and collaborate with others. Create your first team to get started."
msgstr "Teams helfen Ihnen, Ihre Arbeit zu organisieren und mit anderen zusammenzuarbeiten. Erstellen Sie Ihr erstes Team, um loszulegen."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Teams that belong to this organisation."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "Teams that this organisation group is currently assigned to"
msgstr "Teams, denen diese Organisationsgruppe derzeit zugewiesen ist"
@@ -12329,6 +12297,8 @@ msgstr "Unbekannter Name"
#: apps/remix/app/components/general/claim-limit-fields.tsx
#: apps/remix/app/components/general/organisation-usage-panel.tsx
#: apps/remix/app/components/tables/admin-claims-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Unlimited"
msgstr "Unbegrenzt"
@@ -12621,18 +12591,15 @@ msgstr "Hochladen"
msgid "URL"
msgstr "URL"
#. placeholder {0}: selectedStat?.period || 'N/A'
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Usage for period: {0}"
msgstr "Nutzung für Zeitraum: {0}"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Verwenden"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h"
msgstr ""
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Use a unique window for each rate limit"
msgstr ""
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
#: apps/remix/app/components/forms/signin.tsx
msgid "Use Authenticator"
@@ -13534,18 +13501,10 @@ msgstr "Whitelabeling, unbegrenzte Mitglieder und mehr"
msgid "Width:"
msgstr "Breite:"
#: apps/remix/app/components/general/rate-limit-array-input.tsx
msgid "Window"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Withdrawing Consent"
msgstr "Zustimmung widerrufen"
#: apps/remix/app/components/general/organisation-usage-panel.tsx
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/forms/public-profile-form.tsx
msgid "Write a description to display on your public profile"
msgstr "Schreiben Sie eine Beschreibung, die in Ihrem öffentlichen Profil angezeigt wird"

Some files were not shown because too many files have changed in this diff Show More