Compare commits

...
Author SHA1 Message Date
Lucas Smith 389390c884 v2.18.0 2026-09-09 11:34:10 +10:00
Ephraim Duncan c5acba538e chore(auth): remove commented account creation code (#3344) 2026-09-09 11:04:15 +10:00
Lucas Smith f5ab8a8ae3 fix: resolve build errors (#3352) 2026-09-09 11:04:01 +10:00
Ephraim Duncan 3b20c2f960 chore(ui): remove unused text fitting component (#3337) 2026-09-09 10:51:35 +10:00
Lucas Smith 5e8a434141 fix: use react router middleware (#3351) 2026-09-09 10:51:26 +10:00
David Nguyen 6a8bb4be04 fix: improve invalid bulk template upload error handling (#3326) 2026-09-08 21:03:02 +10:00
Martin Glaser 2cac63a000 fix: block SSRF via IPv4-mapped IPv6 webhook URLs (#2901) (#3166) 2026-09-04 15:42:39 +10:00
Catalin Pit 4aa3583e89 fix: temporary fix for British to US spelling (#3329) 2026-09-04 15:41:11 +10:00
Lucas Smith 30a6b19b47 chore: upgrade to node 24 lts and clean up docker image (#3332)
Upgrade to Node 24 LTS, using the alpine 3.23 tag to handle issues with
streaming zip files on 24.16 which hangs npm ci.

Pin npm to 11.19.1 for min-release-age-exclude support.

Slim the runner image by dropping dev deps, the react-email CLI, and
esbuild,
none of which run in production.

Install turbo from the lockfile version instead of a hardcoded one.
2026-09-04 12:29:02 +10:00
Nathan Delhaye dabb7b7a0d fix: increase signature placeholder size when a timestamp authority is configured (#3328) 2026-09-04 12:26:01 +10:00
Catalin Pit cbb1cf7bef fix: default unset signing transport to local (#3309)
`/api/health` and `/api/certificate-status` reported the cert as
available when `NEXT_PRIVATE_SIGNING_TRANSPORT` was unset, even though
sealing defaults to the local P12 and fails if it is missing,
unreadable, or expired.
2026-09-04 08:17:42 +10:00
Lucas Smith 3ec877a68b fix: add patches back to dockerfile (#3324) 2026-09-01 16:12:08 +10:00
Lucas Smith bf406aa943 chore: upgrade libpdf (#3323) 2026-09-01 14:11:58 +10:00
Catalin Pit a2745ed3c2 docs: clarify team API token and certificate behavior (#3317) 2026-09-01 13:54:57 +10:00
Catalin Pit 937e9376d8 fix: enlarge envelope field resize handle hit area (#3315) 2026-09-01 13:54:20 +10:00
David Nguyen eba71d9be4 fix: prevent dialogs from closing on blur (#3314) 2026-09-01 13:17:48 +10:00
Konrad 5082b475a0 chore: update Polish translation (#3316) 2026-08-28 17:08:53 +10:00
Lucas Smith 4285c88f12 chore: add provider request template (#3311) 2026-08-27 11:25:37 +10:00
Lucas Smith ae4b56dd88 feat: optimise posthog usage and add error tracking (#3301)
Disable autocapture and person profiles for anonymous users, and
redact signing tokens from captured URLs.

Manually wire exception capture into the signing, editor and embed
flows with recipient/envelope context for debugging recipient-reported
issues.

Track activation events (webhooks, api tokens, direct links, embed
sessions) server-side with org attribution, and drop client events
already tracked in-app.
2026-08-27 11:11:51 +10:00
Arunendra Tripathi ee8360585b fix: render adjacent email template variables correctly (#3153) 2026-08-26 11:57:35 +10:00
David Nguyen 9dc83bdb06 fix: allow zooming and dragging uploaded signatures (#3173) 2026-08-26 11:21:40 +10:00
github-actions[bot] e532843aa6 chore: extract translations (#3171) 2026-08-25 21:37:33 +10:00
Ephraim Duncan dd08da7ea7 refactor(trpc): use ts-pattern for envelope document-data resolution (#3222) 2026-08-25 18:29:24 +10:00
Catalin Pit 10f2b80025 feat: last used column for api tokens (#3230) 2026-08-25 18:07:31 +10:00
David Nguyen 73d58bdf31 feat: allow admin recipient search (#3277) 2026-08-25 11:20:11 +10:00
water 1de57a9e43 fix(emails): remove trailing period from document pending email subject (#3213) 2026-08-24 17:05:43 +10:00
Rodrigo Flávio 7f11c163fe fix(i18n): add plural forms for command menu results and item counts (#3207) (#3262) 2026-08-24 16:59:37 +10:00
Rodrigo Flávio 5beb57dbf0 fix(i18n): add Plural support for bulk download limit alert (#3209) (#3260) 2026-08-24 16:53:01 +10:00
misinierijon4-debug ead3104896 fix: add type button to multiselect remove and clear buttons (#3256) 2026-08-24 16:22:48 +10:00
Lucas Smith 75330166cc v2.17.0 2026-08-19 20:34:18 +10:00
134 changed files with 5415 additions and 1203 deletions
@@ -0,0 +1,61 @@
name: 'One-Click Deploy Provider Request'
description: Request a new one-click deployment provider (Railway, Render, etc.) to be added to our README
title: 'One-Click Deploy Provider Request: [Provider Name]'
labels: ['deploy-provider-request']
body:
- type: markdown
attributes:
value: |
Thanks for your interest in adding a one-click deploy option for Documenso!
Each provider we list requires us to create, test, and maintain a deployment template, which is ongoing work on top of everything else. To keep this manageable, we ask that providers (or users) **open an issue instead of a PR** so the community can signal interest.
**How this works:**
- 👍 this issue if you'd like to see Documenso deployable on this provider.
- If community interest is high enough, we'll consider adding it to the README.
- Opening an issue is not a guarantee of inclusion. PRs adding badges without a prior issue and demonstrated interest will be closed.
- type: input
attributes:
label: Provider Name
placeholder: e.g. Railway
validations:
required: true
- type: input
attributes:
label: Provider Website
placeholder: e.g. https://railway.com
validations:
required: true
- type: input
attributes:
label: Deploy/Template URL
description: A link to an existing deployment template or deploy button URL, if one exists.
- type: dropdown
attributes:
label: Who creates and maintains the deployment template?
options:
- The provider
- Me / the community
- Nobody yet
validations:
required: true
- type: textarea
attributes:
label: Testing & Maintenance
description: Has the template been tested against the current Documenso release? How are updates handled when Documenso ships breaking changes (env vars, migrations, Docker changes)?
validations:
required: true
- type: textarea
attributes:
label: Why this provider?
description: Tell us why Documenso users would benefit — existing user base, region coverage, free tier, etc.
validations:
required: true
- type: checkboxes
attributes:
label: Please check the boxes that apply to this request.
options:
- label: I have searched existing issues to make sure this provider has not already been requested.
- label: I understand that inclusion depends on community interest and is not guaranteed.
- label: I understand that PRs adding deploy badges without a prior issue will be closed.
+1 -1
View File
@@ -2,7 +2,7 @@ name: 'Setup node'
inputs:
node_version:
required: false
default: v22.x
default: v24.x
runs:
using: 'composite'
+31 -15
View File
@@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
To run Documenso locally, you will need
- Node.js (v22 or above)
- Node.js (v24 or above)
- Postgres SQL Database
- Docker (optional)
@@ -186,21 +186,37 @@ For full instructions, requirements, and configuration details, see the [Self Ho
### One-Click Deploys
#### Railway
> [!NOTE]
> Want to see another provider listed here? Please [open a provider request](https://github.com/documenso/documenso/issues/new?template=deploy-provider-request.yml) instead of a PR so the community can signal interest. PRs adding deploy badges without a prior issue will be closed.
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
#### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
#### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
#### Elestio
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/documenso)
<table>
<tr>
<td align="center" width="200">
<a href="https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.com/button.svg" alt="Deploy on Railway" height="40" />
</a>
</td>
<td align="center" width="200">
<a href="https://render.com/deploy?repo=https://github.com/documenso/documenso">
<img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" height="40" />
</a>
</td>
<td align="center" width="200">
<a href="https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile">
<img src="https://www.koyeb.com/static/images/deploy/button.svg" alt="Deploy to Koyeb" height="40" />
</a>
</td>
</tr>
<tr>
<td align="center" width="200">
<a href="https://elest.io/open-source/documenso">
<img src="https://elest.io/images/logos/deploy-to-elestio-btn.png" alt="Deploy on Elestio" height="40" />
</a>
</td>
<td align="center" width="200"></td>
<td align="center" width="200"></td>
</tr>
</table>
## Security
@@ -95,7 +95,7 @@ Documents created with a team token belong to that team:
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "DOCUMENT",
@@ -157,11 +157,11 @@ Retrieve all documents belonging to the team:
```bash
# List all team documents
curl -X GET "https://app.documenso.com/api/v2/envelope" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
-H "Authorization: api_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
@@ -191,7 +191,7 @@ Templates created with a team token are shared across the team.
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/template/create" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"title": "NDA Template",
@@ -269,7 +269,7 @@ console.log('Created team template:', template.id);
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/template" \
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
@@ -102,7 +102,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| Variable | Description | Default |
| ------------------------------------------- | -------------------------------------------------------------- | ------------------------- |
| `PORT` | Port the application listens on | `3000` |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | Path to signing certificate inside container — set to the volume-mount path (e.g. `/opt/documenso/cert.p12`). Only Docker Compose defaults this; plain `docker run` must set it explicitly | - |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for the signing certificate | - |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
@@ -136,6 +136,7 @@ docker run -d \
-e NEXT_PUBLIC_WEBAPP_URL="https://sign.example.com" \
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@db-host:5432/documenso" \
-e NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH="/opt/documenso/cert.p12" \
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" \
-e NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" \
-e NEXT_PRIVATE_SMTP_HOST="smtp.example.com" \
@@ -154,6 +155,12 @@ A signing certificate is required for document signing. You have two options for
- **Volume mount** — mount a `.p12` file from the host into the container at `/opt/documenso/cert.p12` (shown above). This is the simplest approach for small to moderate deployments.
- **Base64-encoded contents** — set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` with the base64-encoded certificate string. Use this when file mounting is not available (e.g., Railway, Vercel).
<Callout type="warn">
Plain `docker run` deployments must set `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` explicitly. This
prevents production deployments from accidentally using the insecure example certificate.
Docker Compose sets the file path for you.
</Callout>
For production deployments that require Adobe Approved Trust List recognition, consider using a [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) or another external HSM.
<Callout type="warn">
@@ -178,6 +185,7 @@ NEXT_PUBLIC_WEBAPP_URL=https://sign.example.com
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
NEXT_PRIVATE_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
NEXT_PRIVATE_DIRECT_DATABASE_URL=postgresql://user:password@db-host:5432/documenso
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
NEXT_PRIVATE_SMTP_TRANSPORT=smtp-auth
NEXT_PRIVATE_SMTP_HOST=smtp.example.com
@@ -203,6 +211,12 @@ docker run -d \
Documenso provides health check endpoints for monitoring:
<Callout type="info">
If a certificate is mounted but signing fails, ensure `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH`
explicitly points to its path inside the container. Production does not use the development
example certificate as a fallback.
</Callout>
| Endpoint | Purpose |
| ------------------------- | -------------------------------------------------------------- |
| `/api/health` | Checks database connectivity and certificate status |
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
## Prerequisites
- Node.js 22 or later
- npm 11 or later
- Node.js 24 or later
- npm 11.17 or later
- PostgreSQL 14 or later
- A Linux server (for systemd service setup)
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
| Requirement | Version |
| ----------- | ------- |
| Node.js | 22+ |
| npm | 11+ |
| Node.js | 24+ |
| npm | 11.17+ |
---
@@ -169,7 +169,7 @@ Documenso runs on:
| MySQL/MariaDB | PostgreSQL-specific features required |
| SQLite | Not suitable for production workloads |
| MongoDB | Relational database required |
| Node.js < 22 | Modern JavaScript features required |
| Node.js < 24 | Modern JavaScript features required |
---
@@ -34,7 +34,7 @@ To access the preferences, navigate to either the organisation or teams settings
| **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. |
| **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). |
| **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). |
| **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. |
| **Delegate Document Ownership** | By default, documents created with a team API token are owned by the user who created the token. Enable this setting to let supported API requests assign ownership to another team member. |
| **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. |
Document visibility, language, and signature settings can be overridden per document.
-25
View File
@@ -1,25 +0,0 @@
FROM oven/bun:1 AS dependencies-env
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --production
FROM dependencies-env AS build-env
COPY ./package.json bun.lockb /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build
FROM dependencies-env
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "run", "start"]
-26
View File
@@ -1,26 +0,0 @@
FROM node:20-alpine AS dependencies-env
RUN npm i -g pnpm
COPY . /app
FROM dependencies-env AS development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile
FROM dependencies-env AS production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile
FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build
FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -71,6 +72,7 @@ export const EnvelopeDistributeDialog = ({
const { toast } = useToast();
const { t, i18n } = useLingui();
const navigate = useNavigate();
const analytics = useAnalytics();
const [isOpen, setIsOpen] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
@@ -200,6 +202,12 @@ export const EnvelopeDistributeDialog = ({
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'distribute_document',
envelopeId: envelope.id,
});
const errorMessage = getDistributeErrorMessage(error.code);
toast({
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TEnvelope } from '@documenso/lib/types/envelope';
@@ -51,6 +52,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
const { toast } = useToast();
const { t, i18n } = useLingui();
const analytics = useAnalytics();
const [isOpen, setIsOpen] = useState(false);
@@ -95,6 +97,13 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'redistribute_document',
envelopeId: envelope.id,
});
const errorMessage = getDistributeErrorMessage(error.code);
toast({
@@ -290,10 +290,11 @@ export const EnvelopesBulkDownloadDialog = ({
{isOverDownloadLimit && (
<Alert variant="warning">
<AlertDescription>
<Trans>
You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to
continue.
</Trans>
<Plural
value={MAX_BULK_DOWNLOAD_ENVELOPES}
one="You can download up to # document at a time. Deselect some documents to continue."
other="You can download up to # documents at a time. Deselect some documents to continue."
/>
</AlertDescription>
</Alert>
)}
@@ -1,4 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@@ -17,7 +20,9 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
@@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
@@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) {
return;
}
setOpen(value);
if (!value) {
setValidationError(null);
form.reset();
}
};
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
@@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
};
const onSubmit = async (values: TBulkSendFormSchema) => {
setValidationError(null);
try {
const csv = await values.file.text();
await uploadBulkSend({
const result = await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
if (!result.success) {
setValidationError(result.error);
return;
}
toast({
title: _(msg`Success`),
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
});
setOpen(false);
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
const error = AppError.parseError(err);
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
}
};
return (
<Dialog>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
@@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValidationError(null);
onChange(file);
}
}}
@@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => onChange(null)}
onClick={() => {
setValidationError(null);
form.resetField('file');
}}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
@@ -218,6 +257,67 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
)}
/>
{validationError !== null && (
<Alert variant="destructive">
<AlertDescription className="max-h-32 overflow-y-auto">
{match(validationError)
.with({ type: 'PARSE_ERROR' }, () => (
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
))
.with({ type: 'EMPTY' }, () => (
<Trans>
The CSV does not contain any rows. Please add at least one row of recipient details.
</Trans>
))
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
<Trans>
The CSV contains {rowCount} rows. A maximum of {maxRows} rows is allowed per upload.
</Trans>
))
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
<>
<Trans>
The CSV is missing the following required columns. Please download the template CSV for the
correct format.
</Trans>
<ul className="mt-1 list-inside list-disc">
{missingColumns.map((column) => (
<li key={column} className="font-mono">
{column}
</li>
))}
</ul>
</>
))
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
<>
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
<ul className="mt-1 list-inside list-disc">
{rowErrors.map((rowError, index) => (
<li key={index}>
<Trans>
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
email or empty
</Trans>
</li>
))}
</ul>
</>
))
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
code === AppErrorCode.LIMIT_EXCEEDED ? (
<Trans>The CSV exceeds the maximum file size.</Trans>
) : (
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
),
)
.exhaustive()}
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="sendImmediately"
@@ -240,7 +340,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Trans>Cancel</Trans>
</Button>
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
@@ -77,6 +78,7 @@ export const EmbedDirectTemplateClientPage = ({
}: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const [searchParams] = useSearchParams();
@@ -264,6 +266,13 @@ export const EmbedDirectTemplateClientPage = ({
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
analytics.captureException(err, {
source: 'embed',
location: 'direct_template',
recipientId: recipient.id,
envelopeId,
});
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
@@ -308,6 +317,14 @@ export const EmbedDirectTemplateClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
envelopeId,
});
setHasFinishedInit(true);
}
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
@@ -75,6 +76,7 @@ export const EmbedSignDocumentV1ClientPage = ({
}: EmbedSignDocumentV1ClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
@@ -154,6 +156,14 @@ export const EmbedSignDocumentV1ClientPage = ({
setHasCompletedDocument(true);
} catch (err) {
analytics.captureException(err, {
source: 'embed',
location: 'complete_document',
recipientId: recipient.id,
documentId,
envelopeId,
});
if (window.parent) {
window.parent.postMessage(
{
@@ -236,6 +246,15 @@ export const EmbedSignDocumentV1ClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
documentId,
envelopeId,
});
setHasFinishedInit(true);
}
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
@@ -25,6 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({
allowWhitelabelling = false,
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const analytics = useAnalytics();
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
useRequiredEnvelopeSigningContext();
@@ -170,6 +172,14 @@ export const EmbedSignDocumentV2ClientPage = ({
}
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'embed_init',
recipientId: recipient.id,
envelopeId: envelope.id,
});
setHasFinishedInit(true);
}
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -57,6 +58,7 @@ export const MultiSignDocumentSigningView = ({
}: MultiSignDocumentSigningViewProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
@@ -101,6 +103,13 @@ export const MultiSignDocumentSigningView = ({
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'sign_field',
recipientId,
documentId: document?.id,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
@@ -120,6 +129,13 @@ export const MultiSignDocumentSigningView = ({
}
console.error(err);
analytics.captureException(err, {
source: 'embed',
location: 'remove_field',
recipientId,
documentId: document?.id,
});
}
};
@@ -140,6 +156,13 @@ export const MultiSignDocumentSigningView = ({
recipientId,
});
} catch (err) {
analytics.captureException(err, {
source: 'embed',
location: 'complete_document',
recipientId,
documentId: document?.id,
});
onDocumentError?.();
const error = AppError.parseError(err);
@@ -17,7 +17,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { defaultFilter as commandScore } from 'cmdk';
import {
@@ -596,9 +596,21 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
<span className="ml-auto text-muted-foreground text-xs">
{hasValidSearch ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
isVisibleCountCapped ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural
value={totalVisibleCount}
one="# result"
other="# results"
/>
)
) : (
<Trans>{totalVisibleCount} items</Trans>
<Plural
value={totalVisibleCount}
one="# item"
other="# items"
/>
)}
</span>
</div>
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TTemplate } from '@documenso/lib/types/template';
@@ -41,6 +42,7 @@ export const DirectTemplatePageView = ({
const { _ } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const { email, fullName, setEmail } = useRequiredDocumentSigningContext();
const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext();
@@ -124,6 +126,13 @@ export const DirectTemplatePageView = ({
const error = AppError.parseError(err);
const errorMessage = getDirectTemplateErrorMessage(error.code);
analytics.captureException(err, {
source: 'signing',
location: 'direct_template',
recipientId: directTemplateRecipient.id,
envelopeId: template.envelopeId,
});
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -39,6 +40,7 @@ export const DocumentSigningCheckboxField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -126,6 +128,13 @@ export const DocumentSigningCheckboxField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -157,6 +166,13 @@ export const DocumentSigningCheckboxField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -216,6 +232,13 @@ export const DocumentSigningCheckboxField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while updating the signature.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TRecipientAccessAuth, ZDocumentAccessAuthSchema } from '@documenso/lib/types/document-auth';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
@@ -88,6 +89,7 @@ export const DocumentSigningCompleteDialog = ({
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const analytics = useAnalytics();
const { t, i18n } = useLingui();
const { toast } = useToast();
@@ -179,6 +181,11 @@ export const DocumentSigningCompleteDialog = ({
return;
}
analytics.captureException(error, {
source: 'signing',
location: 'complete_document',
});
// This dialog owns the completion error toast for every signing surface
// so the user gets a specific, actionable message. Callers should run
// their own side effects (e.g. embeds posting document-error) and
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { convertToLocalSystemFormat, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
@@ -39,6 +40,7 @@ export const DocumentSigningDateField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -85,6 +87,13 @@ export const DocumentSigningDateField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -113,6 +122,13 @@ export const DocumentSigningDateField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -35,6 +36,7 @@ export const DocumentSigningDropdownField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -86,6 +88,13 @@ export const DocumentSigningDropdownField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -120,6 +129,13 @@ export const DocumentSigningDropdownField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -33,6 +34,7 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { email: providedEmail } = useRequiredDocumentSigningContext();
@@ -78,6 +80,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -106,6 +115,13 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -38,6 +39,7 @@ export const DocumentSigningInitialsField = ({
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { fullName } = useRequiredDocumentSigningContext();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -84,6 +86,13 @@ export const DocumentSigningInitialsField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -112,6 +121,13 @@ export const DocumentSigningInitialsField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -39,6 +40,7 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
@@ -116,6 +118,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -144,6 +153,13 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,4 +1,5 @@
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -47,6 +48,7 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -142,6 +144,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -193,6 +202,13 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,4 +1,3 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
@@ -83,8 +82,6 @@ export const DocumentSigningPageViewV1 = ({
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const [isExpanded, setIsExpanded] = useState(false);
@@ -118,12 +115,6 @@ export const DocumentSigningPageViewV1 = ({
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -31,6 +32,7 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -91,6 +93,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -120,6 +129,13 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the selection.`),
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -41,6 +42,7 @@ export function DocumentSigningRejectDialog({
}: DocumentSigningRejectDialogProps) {
const { t } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
@@ -76,6 +78,12 @@ export function DocumentSigningRejectDialog({
window.location.href = `/sign/${token}/rejected`;
}
} catch (err) {
analytics.captureException(err, {
source: 'signing',
location: 'reject_document',
documentId,
});
toast({
title: t`Error`,
description: t`An error occurred while rejecting the document. Please try again.`,
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -47,6 +48,7 @@ export const DocumentSigningSignatureField = ({
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient } = useDocumentSigningRecipientContext();
@@ -157,6 +159,13 @@ export const DocumentSigningSignatureField = ({
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
@@ -183,6 +192,13 @@ export const DocumentSigningSignatureField = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
@@ -1,4 +1,5 @@
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
@@ -50,6 +51,7 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const analytics = useAnalytics();
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
@@ -170,6 +172,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: isAssistantMode
@@ -200,6 +209,13 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'remove_field',
fieldType: field.type,
recipientId: field.recipientId,
});
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
@@ -1,5 +1,4 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
@@ -38,7 +37,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
const team = useCurrentTeam();
const navigate = useNavigate();
const analytics = useAnalytics();
const organisation = useCurrentOrganisation();
const userTimezone =
@@ -103,12 +101,6 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
description: _(msg`Your document has been uploaded successfully.`),
duration: 5000,
});
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
// Handle legacy template creation.
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@@ -37,8 +38,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
/** How far past a resize handle you can still grab it, in screen pixels. */
const TRANSFORMER_ANCHOR_HIT_STROKE_PX = 24;
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const analytics = useAnalytics();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -276,6 +281,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: envelope.id,
});
setRenderError(true);
}
};
@@ -350,6 +362,9 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
shouldOverdrawWholeArea: true,
ignoreStroke: true,
flipEnabled: false,
anchorStyleFunc: (anchor) => {
anchor.hitStrokeWidth(TRANSFORMER_ANCHOR_HIT_STROKE_PX / scale);
},
boundBoxFunc: (oldBox, newBox) => {
// Enforce minimum size
if (newBox.width < 30 || newBox.height < 20) {
@@ -1,4 +1,5 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
@@ -45,6 +46,7 @@ export const EnvelopeEditorUploadPage = () => {
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const analytics = useAnalytics();
const {
envelope,
@@ -213,6 +215,12 @@ export const EnvelopeEditorUploadPage = () => {
const { data } = await createPromise.catch((error) => {
console.error(error);
analytics.captureException(error, {
source: isEmbedded ? 'embed' : 'editor',
location: 'create_envelope_items',
envelopeId: envelope.id,
});
// Set error state on files in batch upload.
setLocalFiles((prev) =>
prev.map((uploadingFile) =>
@@ -290,6 +298,12 @@ export const EnvelopeEditorUploadPage = () => {
} catch (error) {
console.error(error);
analytics.captureException(error, {
source: isEmbedded ? 'embed' : 'editor',
location: 'replace_pdf',
envelopeId: envelope.id,
});
toast({
title: t`Replace failed`,
description: t`Something went wrong while replacing the PDF`,
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import {
type PageRenderData,
@@ -18,6 +19,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { i18n } = useLingui();
const analytics = useAnalytics();
const {
envelopeStatus,
@@ -114,6 +116,13 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'envelope_page_render',
envelopeId: currentEnvelopeItem?.envelopeId,
});
setRenderError(true);
}
};
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import {
type PageRenderData,
@@ -53,6 +54,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const analytics = useAnalytics();
const {
envelopeData,
@@ -426,6 +428,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
unsafeRenderFieldOnLayer(unparsedField, fieldCanvasStyleCache);
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'page_render',
recipientId: recipient.id,
envelopeId: envelope.id,
});
setRenderError(true);
}
};
@@ -507,6 +517,14 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: 'signing',
location: 'sign_field',
fieldType: payload.type,
recipientId: recipient.id,
envelopeId: envelope.id,
});
toast({
title: t`Error`,
description: t`An error occurred while signing the field.`,
@@ -118,12 +118,6 @@ export const EnvelopeSignerCompleteDialog = () => {
title: t`Document already signed`,
description: t`This document was already signed and no further action was taken.`,
});
} else {
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
}
if (onDocumentCompleted) {
@@ -148,6 +142,13 @@ export const EnvelopeSignerCompleteDialog = () => {
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
analytics.captureException(err, {
source: 'signing',
location: 'complete_document',
recipientId: recipient.id,
envelopeId: envelope.id,
});
onDocumentError?.();
}
@@ -221,6 +222,13 @@ export const EnvelopeSignerCompleteDialog = () => {
} catch (err) {
console.log('err', err);
analytics.captureException(err, {
source: 'signing',
location: 'complete_document_next_signer',
recipientId: recipient.id,
envelopeId: envelope.id,
});
onDocumentError?.();
// Rethrow so DocumentSigningCompleteDialog can toast a specific
@@ -93,14 +93,6 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
duration: 5000,
});
if (type === EnvelopeType.DOCUMENT) {
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
const pathPrefix = type === EnvelopeType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
@@ -109,6 +101,11 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
} catch (err) {
const error = AppError.parseError(err);
analytics.captureException(err, {
source: 'editor',
location: 'upload_document',
});
const errorMessage = getUploadErrorMessage(error.code);
toast({
@@ -1,4 +1,5 @@
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
@@ -34,6 +35,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const { t, i18n } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const analytics = useAnalytics();
const team = useCurrentTeam();
@@ -112,6 +114,11 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
console.error(err);
analytics.captureException(err, {
source: 'editor',
location: 'upload_document',
});
const errorMessage = getUploadErrorMessage(error.code);
toast({
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { ImageLoadingState, PageRenderData } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '@documenso/ui/lib/utils';
@@ -68,6 +69,7 @@ export default function PDFViewer({
}: PDFViewerProps) {
const { t } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const $el = useRef<HTMLDivElement>(null);
@@ -150,6 +152,11 @@ export default function PDFViewer({
console.error(err);
setLoadingState('error');
analytics.captureException(err, {
source: 'pdf_viewer',
location: 'pdf_load',
});
toast({
title: t`Error`,
description: t`An error occurred while loading the document.`,
@@ -366,6 +373,8 @@ const PdfViewerPage = ({
* Manages rendering a page from a pdf.
*/
const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }: PdfViewerPageProps) => {
const analytics = useAnalytics();
const [imageLoadingState, setImageLoadingState] = useState<ImageLoadingState>('loading');
const [imageUrl, setImageUrl] = useState('');
@@ -457,6 +466,12 @@ const usePdfPageImage = ({ pageNumber, pdf, scale, scaledWidth, scaledHeight }:
if (!isCancelled) {
console.error(err);
analytics.captureException(err, {
source: 'pdf_viewer',
location: 'pdf_page_render',
});
setImageLoadingState('error');
}
} finally {
@@ -39,13 +39,14 @@ const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
/**
* Admin list pages which support prefilling their search from the URL, used
* for the "View all results" links on capped groups. Teams, recipients and
* for the "View all results" links on capped groups. Teams and
* subscriptions have no admin list pages.
*/
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`,
};
export type UseAdminSearchCategoriesOptions = {
+44
View File
@@ -18,6 +18,23 @@ import './utils/polyfills/promise-with-resolvers';
* the page early, leaving dead event handlers (broken dropdowns, native form
* submits).
*/
/**
* Signing and direct template URLs contain recipient tokens which must never
* be sent to PostHog. Recipient context is attached explicitly via
* `recipientId` where needed instead.
*/
const redactTokensFromUrl = (value: string) => {
return value.replace(/(\/(?:sign|d|direct)\/)([^/?#]+)/g, '$1:token');
};
const URL_EVENT_PROPERTIES = [
'$current_url',
'$pathname',
'$referrer',
'$initial_referrer',
'$prev_pageview_pathname',
] as const;
function initPosthog() {
const postHogConfig = extractPostHogConfig();
@@ -25,7 +42,34 @@ function initPosthog() {
void import('posthog-js').then(({ default: posthog }) => {
posthog.init(postHogConfig.key, {
api_host: postHogConfig.host,
// Only create person profiles for identified (authenticated) users,
// anonymous recipients on signing pages stay anonymous.
person_profiles: 'identified_only',
// Explicit events only, autocapture on signing pages blows up usage
// without providing actionable data.
autocapture: false,
capture_pageview: true,
capture_pageleave: false,
capture_exceptions: true,
before_send: (event) => {
if (!event) {
return null;
}
for (const property of URL_EVENT_PROPERTIES) {
const value = event.properties?.[property];
if (typeof value === 'string') {
event.properties[property] = redactTokensFromUrl(value);
}
}
if (event.$set_once && typeof event.$set_once['$initial_current_url'] === 'string') {
event.$set_once['$initial_current_url'] = redactTokensFromUrl(event.$set_once['$initial_current_url']);
}
return event;
},
});
});
}
+4 -3
View File
@@ -7,10 +7,11 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import type { EntryContext, RouterContextProvider } from 'react-router';
import { ServerRouter } from 'react-router';
import { langCookie } from './storage/lang-cookie.server';
import { nonceContext } from './utils/nonce';
export const streamTimeout = 5_000;
@@ -19,7 +20,7 @@ export default async function handleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
loadContext: RouterContextProvider,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
@@ -33,7 +34,7 @@ export default async function handleRequest(
// scripts it injects (route manifest, hydration data, module preloads).
// The same nonce is also exposed to the React tree via the root loader so
// our own inline scripts/styles can carry it.
const nonce = loadContext.nonce || undefined;
const nonce = loadContext.get(nonceContext) || undefined;
return new Promise((resolve, reject) => {
let shellRendered = false;
+13
View File
@@ -0,0 +1,13 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { type MiddlewareFunction, redirect } from 'react-router';
export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => {
const { user } = await getOptionalSession(request);
if (!user || !isAdmin(user)) {
throw redirect('/');
}
return next();
};
+8
View File
@@ -0,0 +1,8 @@
import type { MiddlewareFunction } from 'react-router';
import { getRequestNonce } from '../../server/load-context';
import { nonceContext } from '../utils/nonce';
export const nonceMiddleware: MiddlewareFunction = ({ context }) => {
context.set(nonceContext, getRequestNonce());
};
+15 -2
View File
@@ -1,4 +1,5 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { getBasePath } from '@documenso/lib/constants/app';
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
@@ -9,6 +10,7 @@ import { getOrganisationSession } from '@documenso/trpc/server/organisation-rout
import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import { useEffect } from 'react';
import {
data,
isRouteErrorResponse,
@@ -21,13 +23,16 @@ import {
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import { nonceMiddleware } from '~/middleware/nonce';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta';
import { nonce } from './utils/nonce';
import { nonce, nonceContext } from './utils/nonce';
export const middleware = [nonceMiddleware];
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
@@ -72,7 +77,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
nonce: context.nonce,
nonce: context.get(nonceContext),
session: session.isAuthenticated
? {
user: session.user,
@@ -200,11 +205,19 @@ export default function App() {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error);
}
useEffect(() => {
if (errorCode !== 404) {
analytics.captureException(error, { source: 'app', location: 'root_boundary' });
}
}, [error]);
return <GenericErrorLayout errorCode={errorCode} />;
}
@@ -1,4 +1,4 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,16 +20,18 @@ import {
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import { adminMiddleware } from '~/middleware/admin';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Admin`);
}
export const middleware = [adminMiddleware];
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const { user } = await getOptionalSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
@@ -12,7 +12,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { DocumentStatus } from '~/components/general/document/document-status';
@@ -30,6 +30,12 @@ export default function AdminDocumentsPage() {
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const urlTerm = searchParams?.get?.('term') ?? '';
useEffect(() => {
setTerm(urlTerm);
}, [urlTerm]);
const { data: findDocumentsData, isPending: isFindDocumentsLoading } = trpc.admin.document.find.useQuery(
{
query: debouncedTerm,
@@ -131,7 +137,7 @@ export default function AdminDocumentsPage() {
<div>
<Input
type="search"
placeholder={_(msg`Search by document title, team:123 or user:123`)}
placeholder={_(msg`Search by document title, recipient:123, team:123 or user:123`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
@@ -1,4 +1,5 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@@ -7,6 +8,7 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { ShouldRevalidateFunctionArgs } from 'react-router';
import { isRouteErrorResponse, Link, Outlet, redirect } from 'react-router';
@@ -66,8 +68,18 @@ export default function DocumentsLayout() {
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'editor',
location: 'editor_layout_boundary',
envelopeId: params.id,
});
}, [error]);
const errorCodeMap = {
404: {
subHeading: msg`404 Document not found`,
@@ -68,6 +68,20 @@ export default function ApiTokensPage() {
return i18n.date(row.original.expires);
},
},
{
header: t`Last Used`,
cell: ({ row }) => {
if (!row.original.lastUsedAt) {
return (
<span className="text-muted-foreground">
<Trans>Never</Trans>
</span>
);
}
return <span className="text-foreground">{i18n.date(row.original.lastUsedAt)}</span>;
},
},
{
header: t`Actions`,
cell: ({ row }) => (
@@ -146,6 +160,9 @@ export default function ApiTokensPage() {
<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>
@@ -1,4 +1,5 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@@ -8,6 +9,7 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { useEffect } from 'react';
import { isRouteErrorResponse, Link, Outlet, redirect, type ShouldRevalidateFunctionArgs } from 'react-router';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -84,8 +86,18 @@ export default function TemplatesLayout() {
}
export function ErrorBoundary({ error, params }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'editor',
location: 'editor_layout_boundary',
envelopeId: params.id,
});
}, [error]);
const errorCodeMap = {
404: {
subHeading: msg`404 Template not found`,
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -5,6 +6,7 @@ import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { useEffect } from 'react';
import { isRouteErrorResponse, Link, Outlet } from 'react-router';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -48,8 +50,14 @@ export default function RecipientLayout({ matches }: Route.ComponentProps) {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, { source: 'signing', location: 'recipient_layout_boundary' });
}, [error]);
return (
<GenericErrorLayout
errorCode={errorCode}
@@ -1,8 +1,8 @@
import { getCertificateStatus } from '@documenso/lib/server-only/cert/cert-status';
export const loader = () => {
export const loader = async () => {
try {
const certStatus = getCertificateStatus();
const certStatus = await getCertificateStatus();
return Response.json({
isAvailable: certStatus.isAvailable,
+1 -1
View File
@@ -22,7 +22,7 @@ export const loader = async () => {
}
try {
const certStatus = getCertificateStatus();
const certStatus = await getCertificateStatus();
if (certStatus.isAvailable) {
checks.certificate = { status: 'ok' };
@@ -1,3 +1,4 @@
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
@@ -5,6 +6,7 @@ import {
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { Trans } from '@lingui/react/macro';
import { useEffect } from 'react';
import { isRouteErrorResponse, Outlet, useRouteError } from 'react-router';
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
@@ -47,10 +49,30 @@ export default function Layout() {
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
const analytics = useAnalytics();
const error = useRouteError();
console.log({ routeError: error });
useEffect(() => {
const isExpectedEmbedResponse =
isRouteErrorResponse(error) &&
[
'embed-authentication-required',
'embed-paywall',
'embed-waiting-for-turn',
'embed-recipient-expired',
'embed-document-rejected',
'embed-document-completed',
].includes(error.data?.type);
if (isExpectedEmbedResponse) {
return;
}
analytics.captureException(error, { source: 'embed', location: 'embed_layout_boundary' });
}, [error]);
if (isRouteErrorResponse(error)) {
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
return (
@@ -2,11 +2,13 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { data } from 'react-router';
@@ -93,6 +95,30 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: template.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: template.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: template.envelopeId,
},
});
});
return {
token,
user,
@@ -194,6 +220,30 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
);
}
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: envelope.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: envelope.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: envelope.id,
},
});
});
return {
token,
user,
@@ -2,6 +2,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
@@ -13,6 +14,7 @@ import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
@@ -139,6 +141,30 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
})
: [];
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: document.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: document.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: document.envelopeId,
},
});
});
return {
token,
user,
@@ -272,6 +298,30 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: envelope.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: envelope.teamId,
properties: {
type: 'signing',
version: 'v0',
recipientId: recipient.id,
envelopeId: envelope.id,
},
});
});
return {
token,
user,
@@ -1,8 +1,11 @@
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { ZBaseEmbedAuthoringSchema } from '@documenso/lib/types/embed-authoring-base-schema';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { prisma } from '@documenso/prisma';
import { TrpcProvider } from '@documenso/trpc/react';
import { Spinner } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
@@ -35,6 +38,40 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
});
allowEmbedAuthoringWhiteLabel = organisationClaim.flags.embedAuthoringWhiteLabel ?? false;
// Derive the kind of authoring session from the child route path, e.g.
// /embed/v1/authoring/document/create -> resource 'document', mode 'create'.
// Completed and error pages are not new sessions and are skipped.
const [resource, mode] = url.pathname.split('/').filter(Boolean).slice(3);
const isAuthoringSession =
(resource === 'document' || resource === 'template') && (mode === 'create' || mode === 'edit');
if (isAuthoringSession) {
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: result.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: result.userId,
organisationId: team?.organisationId,
teamId: result.teamId,
properties: {
type: 'authoring',
version: 'v1',
resource,
mode,
},
});
});
}
}
return {
@@ -1,8 +1,11 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { prisma } from '@documenso/prisma';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { useEffect, useLayoutEffect, useState } from 'react';
@@ -42,6 +45,15 @@ export async function loader({ request }: Route.LoaderArgs) {
const firstDocument = envelopes[0]?.document;
if (!firstDocument) {
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
properties: {
type: 'signing',
version: 'v1',
},
});
return superLoaderJson({
envelopes,
user,
@@ -55,6 +67,29 @@ export async function loader({ request }: Route.LoaderArgs) {
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
fireAndForget(async () => {
const team = await prisma.team.findFirst({
where: {
id: firstDocument.teamId,
},
select: {
organisationId: true,
},
});
captureServerEvent({
event: 'App: Embed Session Started',
userId: user?.id,
organisationId: team?.organisationId,
teamId: firstDocument.teamId,
properties: {
type: 'signing',
version: 'v1',
envelopeId: firstDocument.envelopeId,
},
});
});
return superLoaderJson({
envelopes,
user,
@@ -1,5 +1,6 @@
import { PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
@@ -12,7 +13,7 @@ import type { OrganisationSession } from '@documenso/trpc/server/organisation-ro
import { Spinner } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, OrganisationType, TeamMemberRole } from '@prisma/client';
import { useLayoutEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import { isRouteErrorResponse, Outlet, useLoaderData } from 'react-router';
import { match } from 'ts-pattern';
@@ -166,8 +167,17 @@ export default function AuthoringLayout() {
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const analytics = useAnalytics();
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
useEffect(() => {
analytics.captureException(error, {
source: 'embed',
location: 'embed_authoring_boundary',
});
}, [error]);
return (
<div>
{match(errorCode)
@@ -1,5 +1,6 @@
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
@@ -10,6 +11,7 @@ import {
ZEmbedCreateEnvelopeAuthoringSchema,
} from '@documenso/lib/types/envelope-editor';
import type { TEnvelopeFieldAndMeta } from '@documenso/lib/types/field-meta';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { extractDerivedDocumentMeta } from '@documenso/lib/utils/document';
import { buildEmbeddedEditorOptions, buildEmbeddedFeatures } from '@documenso/lib/utils/embed-config';
import { prisma } from '@documenso/prisma';
@@ -76,6 +78,30 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
},
});
fireAndForget(async () => {
const team = result.teamId
? await prisma.team.findFirst({
where: {
id: result.teamId,
},
select: {
organisationId: true,
},
})
: null;
captureServerEvent({
event: 'App: Embed Session Started',
userId: result.userId,
organisationId: team?.organisationId,
teamId: result.teamId ?? undefined,
properties: {
type: 'authoring',
version: 'v2',
},
});
});
return superLoaderJson({
token,
tokenUserId: result.userId,
@@ -1,5 +1,6 @@
import { EnvelopeEditorProvider } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import type { SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
import { captureServerEvent } from '@documenso/lib/server-only/analytics/capture-server-event';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getEditorEnvelopeById } from '@documenso/lib/server-only/envelope/get-editor-envelope-by-id';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
@@ -94,6 +95,18 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
brandingLogo = settings.brandingLogo;
}
captureServerEvent({
event: 'App: Embed Session Started',
userId: result.userId,
organisationId: envelope.team.organisationId,
teamId: result.teamId ?? undefined,
properties: {
type: 'authoring',
version: 'v2',
envelopeId: envelope.id,
},
});
return superLoaderJson({
token,
envelope,
+7 -1
View File
@@ -1,4 +1,10 @@
import { useRouteLoaderData } from 'react-router';
import { createContext, useRouteLoaderData } from 'react-router';
/**
* Per-request CSP nonce. Set by the root route middleware, read with
* `context.get(nonceContext)` in loaders/actions and `entry.server`.
*/
export const nonceContext = createContext<string>('');
/**
* Returns the supplied CSP nonce only when rendering on the server.
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.16.0"
"version": "2.18.0"
}
+3
View File
@@ -8,4 +8,7 @@ export default {
// kept without a trailing slash so they match exactly, and so the bare
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
future: {
v8_middleware: true,
},
} satisfies Config;
+2 -3
View File
@@ -24,12 +24,11 @@ export const appContext = async (c: Context, next: Next) => {
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return next();
return await next();
}
// Add context to any pages you want here.
return next();
return await next();
};
const setAppContext = (c: Context, context: AppContext) => {
+6 -23
View File
@@ -1,33 +1,16 @@
import { getContext } from 'hono/context-storage';
import type { AppLoadContext } from 'react-router';
import { RouterContextProvider } from 'react-router';
import type { HonoEnv } from './router';
import { CSP_NONCE_KEY } from './security-headers';
/**
* Augment React Router's `AppLoadContext` so loaders, actions, and
* `entry.server` can access fields by name without casts.
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
* `hono/context-storage` (enabled in `server/router.ts`).
*/
declare module 'react-router' {
interface AppLoadContext {
/**
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
* so it can be threaded into `<ServerRouter nonce>` and root loader
* data, which then feeds `<Scripts>`, `<Links>`, etc.
*/
nonce: string;
}
}
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
/**
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
* production (`hono-react-router-adapter/node`).
*
* The Hono context isn't passed directly by the adapter, so we read it via
* `hono/context-storage`, which is enabled in `server/router.ts`.
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
*/
export const getLoadContext = (): AppLoadContext => {
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
return { nonce };
};
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
+2 -1
View File
@@ -5,6 +5,7 @@ import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import serverAdapter from 'hono-react-router-adapter/vite';
import type { AppLoadContext } from 'react-router';
import tailwindcss from 'tailwindcss';
import { defineConfig, normalizePath } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
@@ -53,7 +54,7 @@ export default defineConfig({
entry: 'server/router.ts',
getLoadContext: async () => {
const { getLoadContext } = await import('./server/load-context');
return getLoadContext();
return getLoadContext() as unknown as AppLoadContext;
},
exclude: [
// Spread the defaults but replace the /.css$/ rule so that Bull
+28 -12
View File
@@ -1,7 +1,7 @@
###########################
# BASE CONTAINER #
###########################
FROM node:22-alpine3.22 AS base
FROM node:24-alpine3.23 AS base
RUN apk add --no-cache openssl
RUN apk add --no-cache font-freefont
@@ -19,7 +19,10 @@ WORKDIR /app
COPY . .
RUN npm install -g "turbo@^2.10.0"
# Install the exact turbo version resolved in the lockfile, without installing
# the rest of the dependency tree (prune must run before any npm ci).
RUN TURBO_VERSION="$(jq -r '.packages["node_modules/turbo"].version' package-lock.json)" \
&& npm install -g "turbo@${TURBO_VERSION}"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
@@ -39,9 +42,9 @@ RUN apk add --no-cache make cmake g++ openssl bash
WORKDIR /app
# Disable husky from installing hooks
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
ENV NEXT_TELEMETRY_DISABLED=1
# Encryption keys
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
@@ -76,6 +79,7 @@ COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/package-lock.json ./package-lock.json
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
COPY --from=builder /app/patches ./patches
RUN npm ci
@@ -84,17 +88,17 @@ 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"
ENV NODE_OPTIONS="--max-old-space-size=8192"
RUN turbo run build --filter=@documenso/remix...
RUN npx turbo run build --filter=@documenso/remix...
###########################
# RUNNER CONTAINER #
###########################
FROM base AS runner
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV HUSKY=0
ENV DOCKER_OUTPUT=1
# Telemetry credentials (baked into image at build time, can be disabled at runtime)
ARG NEXT_PRIVATE_TELEMETRY_KEY=""
@@ -114,8 +118,19 @@ WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
# Copy the tailwind config files across
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
# Copy the patches across
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
RUN npm ci --only=production
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
# Strip build-time residue that ships as production dependencies but is never
# executed at runtime.
RUN rm -rf \
node_modules/react-email/dist/cli \
node_modules/esbuild \
node_modules/@esbuild \
node_modules/.bin/esbuild \
node_modules/.bin/email
# Automatically leverage output traces to reduce image size
# https://nodejs.org/docs/advanced-features/output-file-tracing
@@ -126,8 +141,9 @@ COPY --from=installer --chown=nodejs:nodejs /app/apps/remix/public ./apps/remix/
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/schema.prisma ./packages/prisma/schema.prisma
COPY --from=installer --chown=nodejs:nodejs /app/packages/prisma/migrations ./packages/prisma/migrations
# Generate the prisma client again
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
# Generate the prisma client again, this time only targeting the client generator
RUN npx prisma generate --schema ./packages/prisma/schema.prisma --generator client \
&& npm cache clean --force
# Get the start script from docker/
+35 -14
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.16.0",
"version": "2.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.16.0",
"version": "2.18.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -102,8 +102,8 @@
"zod-prisma-types": "3.3.5"
},
"engines": {
"node": ">=22.0.0",
"npm": ">=11.11.0"
"node": ">=24.0.0",
"npm": ">=11.17.0"
}
},
"apps/docs": {
@@ -416,7 +416,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.16.0",
"version": "2.18.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -3153,6 +3153,7 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
"integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "10.5.0",
@@ -3164,6 +3165,7 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz",
"integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "10.5.0",
@@ -3174,12 +3176,14 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz",
"integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz",
"integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@commitlint/cli": {
@@ -4802,9 +4806,9 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.1.tgz",
"integrity": "sha512-DWGxWw1na8oFPixz+b6kOgu4tMD8xJLDgyPGVUNqIswO5POHAxsqmTvUvDW0IrwHP7kFRHBV3I3T/KhTABf9rA==",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
@@ -5723,6 +5727,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz",
"integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chevrotain": "^10.5.0",
@@ -10295,12 +10300,14 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.19.3.tgz",
"integrity": "sha512-+D6v7RIF21bJrZAXiiIdW0qR73TleYVCqTDozokdEHdGeqN997O7jJW5z+43s5CVwWTZJIwFGpoeXxDDyXDVxA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/driver-adapter-utils": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-6.19.3.tgz",
"integrity": "sha512-UUxn6VLfKKVqm5n9vexOQgFJ9TCBIxupb7F5FzLQ6iM2VV0WZxhgzbQVqBDsGY+VmQLmqaSoKRusEfy3O5KZpA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3"
@@ -10349,12 +10356,14 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.19.3.tgz",
"integrity": "sha512-rzHJaIZEnEDUWNjjFAimsyMCS9osPxwbwiAbymwFprSHJSAglMxMDVU+xbQUCLeDsjUF09W5ag/aBPL2t3YqgA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/generator-helper": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.19.3.tgz",
"integrity": "sha512-13S8ngSWVKcyuRFqaK/JGMyovjQC5sOKJ9A+ufqePQWeIUbvKO2QY2CmhcV4JAa2vuN22//4Sip5mORVKwS0sw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3",
@@ -10375,6 +10384,7 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-6.19.3.tgz",
"integrity": "sha512-1V5ba+BNtGFFlgoA8ecyAbjmoMWzIrEuebmLbJpeS7qbhnY6vbc1OZ6qc55lI/VAkfdALSL5vePG5KF9P8feLw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.19.3",
@@ -10405,18 +10415,21 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/prisma-schema-wasm/-/prisma-schema-wasm-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-/5JrsJQAOIWSl+9WMM/0ugd737kT9zdMNr7EFaP7ogeMxAhxQ78uaOV6JYGEocD8LC0gZx7MXmVE/CTfYdJ2iA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/schema-engine-wasm": {
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/schema-engine-wasm/-/schema-engine-wasm-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-mXtzeePkSdqpr/HAIclqzQEf+tBzLawy7NQ9AaRCfg8N/cruavpsocgbSwOOfV7JTk9JubyzKuR3tp2bNNuWbQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/schema-files-loader": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/schema-files-loader/-/schema-files-loader-6.19.3.tgz",
"integrity": "sha512-rnoL2PgopghakRZLCZwj+d7LPvjbY9mFholFWuibEKzwO7aqS16s60Hz4wxAnKwqOcS/M6pV7QLrsHBYnNR0KA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/prisma-schema-wasm": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
@@ -16274,6 +16287,7 @@
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz",
"integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "10.5.0",
@@ -16625,6 +16639,7 @@
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"dev": true,
"license": "MIT"
},
"node_modules/collapse-white-space": {
@@ -19871,6 +19886,7 @@
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
@@ -22537,6 +22553,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -26383,6 +26400,7 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.6.2.tgz",
"integrity": "sha512-WX/oENQ0S74r/Wgd2uuHT5i3KbnwLFCP2Fq5ISzrXkus/htOC4uCaQPYuGP2m/wSeKZZCw1RxptTlD+ib7Ht/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@prisma/generator-helper": "^6.16.1",
@@ -26408,6 +26426,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-2.3.0.tgz",
"integrity": "sha512-/+VF2t2DlY+t/27hhyH5ULWxBAAsMBPgOo8Ltq7uXpBz49lUf4XuO1ff9HV0l7J3aDojG+YyczEvY0og9uRqsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mrleebo/prisma-ast": "^0.13.1",
@@ -27608,6 +27627,7 @@
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
"integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==",
"dev": true,
"license": "MIT"
},
"node_modules/rehype-raw": {
@@ -31654,6 +31674,7 @@
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/zod-prisma-types/-/zod-prisma-types-3.3.5.tgz",
"integrity": "sha512-PDuRRCdX1d6ch6UclNvrqM1SLb2qvuaQPvZT/OHRrcxOQqQUnCz009aBc1uxuwMho1+d42TTDCQr+HPXhTSJtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@prisma/dmmf": "^6.16.3",
@@ -32410,17 +32431,17 @@
"nanoid": "^5.1.6",
"prisma": "^6.19.0",
"prisma-extension-kysely": "^3.0.0",
"prisma-json-types-generator": "^3.6.2",
"prisma-kysely": "^2.3.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76",
"zod-prisma-types": "3.3.5"
"zod": "^3.25.76"
},
"devDependencies": {
"dotenv": "^17.2.3",
"dotenv-cli": "^11.0.0",
"prisma-json-types-generator": "^3.6.2",
"prisma-kysely": "^2.3.0",
"tsx": "^4.23.1",
"typescript": "5.6.2"
"typescript": "5.6.2",
"zod-prisma-types": "3.3.5"
}
},
"packages/prisma/node_modules/@esbuild/aix-ppc64": {
+5 -5
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.16.0",
"version": "2.18.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -41,10 +41,10 @@
"translate:extract": "lingui extract --clean",
"translate:compile": "lingui compile"
},
"packageManager": "npm@11.11.0",
"packageManager": "npm@11.19.1",
"engines": {
"npm": ">=11.11.0",
"node": ">=22.0.0"
"npm": ">=11.17.0",
"node": ">=24.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
@@ -91,7 +91,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "5.0.48",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.1",
"@libpdf/core": "^0.4.2",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -10,7 +10,7 @@ test.describe.configure({ mode: 'parallel' });
const nanoid = customAlphabet('1234567890abcdef', 10);
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organizations…';
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
@@ -437,3 +437,98 @@ test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without e
expect(adminSearchRequests).toHaveLength(0);
});
test('[ADMIN][GLOBAL_SEARCH]: capped recipients group links to the admin documents page', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientPrefix = `viewall-recipient-${nanoid()}`;
// Seed 5 documents, each with one recipient email sharing the prefix, to
// hit the 5 result cap on the recipients group.
const documents = [];
for (let i = 0; i < 5; i++) {
documents.push(
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-viewall-${nanoid()}` },
}),
);
}
await apiSignin({ page, email: adminUser.email });
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
// Only the recipients group matches the prefix, so this is its link.
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
'href',
`/admin/documents?term=${encodeURIComponent(`recipient:${recipientPrefix}`)}`,
);
await viewAllOption.click();
await page.waitForURL((url) => url.pathname === '/admin/documents');
// The term input is prefilled with the recipient query and the matching
// documents are listed.
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
for (const document of documents) {
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
}
});
test('[ADMIN][GLOBAL_SEARCH]: view all results updates the documents page when already on it', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientPrefix = `viewall-live-${nanoid()}`;
// Seed 5 documents with recipients matching the prefix to hit the cap, and
// one control document whose recipient does not match: with a stale
// (unfiltered) query the control would show, with the filter it must not.
const documents = [];
for (let i = 0; i < 5; i++) {
documents.push(
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-live-${nanoid()}` },
}),
);
}
const controlDocument = await seedPendingDocument(sender, team.id, [`control-${nanoid()}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-live-control-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
// Start ON the admin documents page: the buggy state initializer has
// already run with an empty term.
await page.goto('/admin/documents');
await expect(page.getByPlaceholder(/Search by document title/)).toBeVisible();
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
await page.getByRole('option').filter({ hasText: 'View all results' }).first().click();
await page.waitForURL((url) => url.searchParams.get('term') === `recipient:${recipientPrefix}`);
// The same-route navigation must update both the input and the results.
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
await expect(page.getByRole('link', { name: documents[0].title })).toBeVisible();
await expect(page.getByRole('link', { name: controlDocument.title })).toHaveCount(0);
});
@@ -411,7 +411,7 @@ test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', asyn
});
// They should NOT see the organisation settings heading for this org.
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
await expect(page.getByText('Organization Settings')).not.toBeVisible();
});
// ─── Access control: UI ──────────────────────────────────────────────────────
@@ -50,9 +50,9 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
});
// Verify we're on the admin organisation page
await expect(page.getByText(`Manage organisation`)).toBeVisible();
await expect(page.getByText(`Manage organization`)).toBeVisible();
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
await expect(page.getByLabel('Organization Name')).toHaveValue(organisation.name);
// Check that the organisation members table shows the correct roles
const ownerRow = page.getByRole('row', { name: ownerUser.email });
@@ -356,7 +356,7 @@ test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
});
// Should show 404 error
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Organization not found' })).toBeVisible({
timeout: 10_000,
});
});
@@ -525,8 +525,8 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
// Should be able to access organisation settings
await expect(page.getByTestId('unified-settings-sidebar')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
await expect(page.getByLabel('Organization Name*')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeEnabled();
// Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
@@ -0,0 +1,226 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../../../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
type AdminFindDocumentsResult = {
data: Array<{ envelopeId: string; title: string }>;
count: number;
};
const callAdminFindDocuments = async (page: Page, query: string) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { query, page: 1, perPage: 20 } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.document.find?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
result: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json as AdminFindDocumentsResult)
: null,
};
};
// ─── Access control ──────────────────────────────────────────────────────────
test('[ADMIN][TRPC][FIND_DOCUMENTS]: non-admin user is rejected with 401', async ({ page }) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
await apiSignin({ page, email: nonAdminUser.email });
const { res } = await callAdminFindDocuments(page, 'recipient:anything');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
// ─── recipient: prefix ───────────────────────────────────────────────────────
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient email', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientEmail = `recipient-find-${nanoid()}@test.documenso.com`;
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientEmail], {
createDocumentOptions: { title: `recipient-find-match-${nanoid()}` },
});
const otherDocument = await seedPendingDocument(sender, team.id, [`other-${nanoid()}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-find-other-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientEmail}`);
expect(res.ok()).toBeTruthy();
expect(result?.count).toBe(1);
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
expect(result?.data.map((document) => document.envelopeId)).not.toContain(otherDocument.id);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient name case-insensitively', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const recipientName = `recipient-name-${nanoid()}`;
const { user: recipientUser } = await seedUser({ name: recipientName });
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientUser], {
createDocumentOptions: { title: `recipient-name-match-${nanoid()}` },
});
await apiSignin({ page, email: adminUser.email });
// Query the uppercased name: matching must be case-insensitive.
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientName.toUpperCase()}`);
expect(res.ok()).toBeTruthy();
expect(result?.count).toBe(1);
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with empty value returns no results', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const emptyValueSearch = await callAdminFindDocuments(page, 'recipient:');
expect(emptyValueSearch.res.ok()).toBeTruthy();
expect(emptyValueSearch.result?.data).toEqual([]);
expect(emptyValueSearch.result?.count).toBe(0);
// Whitespace-only values are treated the same as empty.
const whitespaceValueSearch = await callAdminFindDocuments(page, 'recipient: ');
expect(whitespaceValueSearch.res.ok()).toBeTruthy();
expect(whitespaceValueSearch.result?.data).toEqual([]);
expect(whitespaceValueSearch.result?.count).toBe(0);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with no matches returns no results', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
const { res, result } = await callAdminFindDocuments(page, 'recipient:zzzz-no-such-recipient-9x7q');
expect(res.ok()).toBeTruthy();
expect(result?.data).toEqual([]);
expect(result?.count).toBe(0);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with numeric value matches by exact recipient ID', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
const matchingDocument = await seedPendingDocument(sender, team.id, [`recipient-id-${nanoid()}@test.documenso.com`], {
createDocumentOptions: { title: `recipient-id-match-${nanoid()}` },
});
const recipient = matchingDocument.recipients[0];
// A decoy whose recipient email contains the ID as text: an exact ID lookup
// must exclude it, while an accidental "ID or contains" match would not.
const decoyDocument = await seedPendingDocument(
sender,
team.id,
[`decoy-${recipient.id}-${nanoid()}@test.documenso.com`],
{
createDocumentOptions: { title: `recipient-id-decoy-${nanoid()}` },
},
);
await apiSignin({ page, email: adminUser.email });
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipient.id}`);
expect(res.ok()).toBeTruthy();
expect(result?.count).toBe(1);
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
expect(result?.data.map((document) => document.envelopeId)).not.toContain(decoyDocument.id);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with nonexistent recipient ID returns no results', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
// Int4 max - 1: a valid ID-shaped number that no autoincrement recipient
// sequence will plausibly reach, and that no other test seeds as text.
const { res, result } = await callAdminFindDocuments(page, 'recipient:2147483646');
expect(res.ok()).toBeTruthy();
expect(result?.data).toEqual([]);
expect(result?.count).toBe(0);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with oversized number falls back to text search', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const { user: sender, team } = await seedUser();
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
// treated as text (and must not 500).
const oversizedNumber = '99999999999999';
const matchingDocument = await seedPendingDocument(
sender,
team.id,
[`${oversizedNumber}-${nanoid()}@test.documenso.com`],
{
createDocumentOptions: { title: `recipient-oversized-${nanoid()}` },
},
);
await apiSignin({ page, email: adminUser.email });
const { res, result } = await callAdminFindDocuments(page, `recipient:${oversizedNumber}`);
expect(res.ok()).toBeTruthy();
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
});
test('[ADMIN][TRPC][FIND_DOCUMENTS]: user and team prefixes with oversized numbers return no results', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({ page, email: adminUser.email });
// 99999999999999 exceeds Int4, so it can never be a valid user or team ID.
// The ID schema must reject it so the query returns empty instead of
// overflowing Postgres and erroring.
const userSearch = await callAdminFindDocuments(page, 'user:99999999999999');
expect(userSearch.res.ok()).toBeTruthy();
expect(userSearch.result?.data).toEqual([]);
expect(userSearch.result?.count).toBe(0);
const teamSearch = await callAdminFindDocuments(page, 'team:99999999999999');
expect(teamSearch.res.ok()).toBeTruthy();
expect(teamSearch.result?.data).toEqual([]);
expect(teamSearch.result?.count).toBe(0);
});
@@ -408,7 +408,7 @@ test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) =
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
await expectToastTextToBeVisible(page, 'Documents cancelled');
await expectToastTextToBeVisible(page, 'Documents canceled');
// Selection clears after a successful cancel.
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
@@ -455,7 +455,7 @@ test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
// Only one of the three was pending, so this is a partial result.
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
await expectToastTextToBeVisible(page, 'Documents partially canceled');
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
where: { id: pending.id },
@@ -505,7 +505,7 @@ test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', as
// The server rejects the cancellation for a document the MEMBER does not own,
// so it reports zero cancelled (a partial result with the document in failedIds).
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
await expectToastTextToBeVisible(page, 'Documents partially canceled');
// The document remains pending.
const envelope = await prisma.envelope.findFirstOrThrow({
@@ -41,7 +41,7 @@ const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: s
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
if (reason) {
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
await page.getByPlaceholder('Add an optional reason for canceling this document').fill(reason);
}
await page.getByRole('button', { name: 'Cancel document' }).click();
@@ -58,13 +58,13 @@ test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
// The document must remain in the dashboard, unlike deleting a pending document.
await checkDocumentCounts(page, { inbox: 0, pending: 0, cancelled: 1, all: 1 });
// The cancelled document is still listed.
await selectDocumentStatusFilter(page, 'Cancelled');
await selectDocumentStatusFilter(page, 'Canceled');
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
// The envelope status is persisted as CANCELLED.
@@ -95,7 +95,7 @@ test('[DOCUMENTS]: cancelling a pending document retains it for recipients', asy
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
await apiSignout({ page });
@@ -125,10 +125,10 @@ test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner
});
await cancelDocumentViaUi(page, 'Document 1 - Pending');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
await selectDocumentStatusFilter(page, 'Cancelled');
await selectDocumentStatusFilter(page, 'Canceled');
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
@@ -328,7 +328,7 @@ test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
await expectToastTextToBeVisible(page, 'Document cancelled');
await expectToastTextToBeVisible(page, 'Document canceled');
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
@@ -29,11 +29,11 @@ test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
await page.waitForURL(`/settings/organisations`);
await expectTextToBeVisible(page, 'No results found');
await page.getByRole('button', { name: 'Create organisation' }).click();
await page.getByRole('button', { name: 'Create organization' }).click();
await page.getByLabel('Organisation Name*').fill('test');
await page.getByLabel('Organization Name*').fill('test');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Your organisation has been created').first()).toBeVisible();
await expect(page.getByText('Your organization has been created').first()).toBeVisible();
await page.reload();
await page.getByRole('row').filter({ hasText: 'test' }).getByRole('link').nth(1).click();
@@ -53,12 +53,12 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
const updatedOrganisationId = `organisation-${Date.now()}`;
// Update team.
await page.getByLabel('Organisation Name*').click();
await page.getByLabel('Organisation Name*').clear();
await page.getByLabel('Organisation Name*').fill(updatedOrganisationId);
await page.getByLabel('Organisation URL*').click();
await page.getByLabel('Organisation URL*').clear();
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
await page.getByLabel('Organization Name*').click();
await page.getByLabel('Organization Name*').clear();
await page.getByLabel('Organization Name*').fill(updatedOrganisationId);
await page.getByLabel('Organization URL*').click();
await page.getByLabel('Organization URL*').clear();
await page.getByLabel('Organization URL*').fill(updatedOrganisationId);
await page.getByRole('button', { name: 'Save changes' }).click();
@@ -277,8 +277,8 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Create a custom group A with 3 members "ORGANISATION ADMIN" to check that they get the correct roles.
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
await page.getByRole('option', { name: 'Organization Admin' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member1' }).click();
await page.getByRole('option', { name: 'Member2' }).click();
@@ -291,16 +291,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm org roles have been applied to these members.
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(memberEmail3)).toBeVisible();
// Test updating the group.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('link', { name: 'Manage' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
await page.getByRole('option', { name: 'Organisation Member' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organization Admin' }).click();
await page.getByRole('option', { name: 'Organization Member' }).click();
// Remove Member3 by clicking the X on its chip in the multiselect.
await page
.getByTestId('group-members-picker')
@@ -327,16 +327,16 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm admins still get admin roles.
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail1)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail2)).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: 'Organization Admin' }).getByText(adminEmail3)).toBeVisible();
// Create another custom group with 3 members with "ORGANISATION MEMBER" role.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Organization Member' }).click();
await page.getByRole('option', { name: 'Organization Admin' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member4' }).click();
await page.getByRole('option', { name: 'Member5' }).click();
@@ -537,6 +537,6 @@ test('[ORGANISATIONS]: leave organisation', async ({ page }) => {
await page.getByRole('button', { name: 'Leave' }).click();
await page.getByRole('button', { name: 'Leave' }).click();
await expect(page.getByText('You have successfully left this organisation').first()).toBeVisible();
await expect(page.getByText('You have successfully left this organization').first()).toBeVisible();
await expect(page.getByText('No results found').first()).toBeVisible();
});
@@ -7,8 +7,8 @@ import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
const BANNER_NEARING_TEXT = 'Your organisation is approaching a fair use limit';
const BANNER_EXCEEDED_TEXT = 'Your organization has exceeded a fair use limit';
const BANNER_NEARING_TEXT = 'Your organization is approaching a fair use limit';
type SeedQuotaStateOptions = {
organisationId: string;
@@ -162,7 +162,7 @@ test('[QUOTA BANNER]: is hidden for free-claim organisations', async ({ page })
});
// Anchor on a stable element so banner-absence is meaningful (page fully loaded).
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organization Name*')).toBeVisible();
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
await expect(page.getByRole('button', { name: 'Learn more' })).toBeHidden();
@@ -176,7 +176,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('option', { name: 'Inherit from organization' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
@@ -254,9 +254,9 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page
.getByTestId('inheritable-email-document-settings')
.getByRole('combobox')
.filter({ hasText: 'Inherit from organisation' })
.filter({ hasText: 'Inherit from organization' })
.click();
await page.getByRole('option', { name: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Override organization settings' }).click();
// Update some email settings
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
@@ -308,8 +308,8 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('combobox').filter({ hasText: 'Override organization settings' }).click();
await page.getByRole('option', { name: 'Inherit from organization' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
@@ -248,7 +248,7 @@ test.describe('Unified Settings', () => {
// Wait for the organisation page to actually render — asserting straight after
// `waitForURL` can read the previous scope's still-mounted sidebar and pass falsely.
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation Settings');
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organization Settings');
await expect(trigger).toContainText(selected.name);
@@ -283,7 +283,7 @@ test.describe('Unified Settings', () => {
// Selecting the inherit option stages the field back to inherited.
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: /inherit from organisation/i }).click();
await page.getByRole('option', { name: /inherit from organization/i }).click();
await expect(langStatus).toHaveText(/inherited/i);
});
@@ -450,7 +450,7 @@ test.describe('Unified Settings', () => {
// An empty group would just look broken, so it explains itself directly under the switcher.
const emptyState = sidebar.getByTestId('unified-settings-organisation-empty-state');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText(/permission to manage this organisation/i);
await expect(emptyState).toContainText(/permission to manage this organization/i);
// Team and account pages remain navigable.
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
@@ -0,0 +1,80 @@
import { prisma } from '@documenso/prisma';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { expect, test } from '@playwright/test';
import { EnvelopeType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
test('[TEMPLATES]: bulk send via CSV from the table action dropdown', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedTemplate({
title: 'Bulk send template',
userId: owner.id,
teamId: team.id,
});
const uniqueRecipientEmail = `bulk-send-${Date.now()}@documenso.com`;
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates`,
});
const actionBtn = page
.getByRole('row', { name: 'Bulk send template' })
.getByRole('cell', { name: 'Use Template' })
.getByRole('button')
.nth(1);
await openDropdownMenu(page, actionBtn);
await page.getByText('Bulk Send via CSV').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Bulk Send Template via CSV' });
await expect(dialog).toBeVisible();
// Opening the native file picker blurs the window. Radix dropdown menus close themselves on
// window blur, which used to unmount this dialog when it was nested inside the menu content,
// silently discarding the user's file selection.
await page.evaluate(() => window.dispatchEvent(new Event('blur')));
await expect(dialog).toBeVisible();
const csv = ['recipient_1_email,recipient_1_name', `${uniqueRecipientEmail},Bulk Recipient`].join('\n');
await dialog.locator('input[type="file"]').setInputFiles({
name: 'bulk-send.csv',
mimeType: 'text/csv',
buffer: Buffer.from(csv),
});
await expect(dialog.getByText('bulk-send.csv')).toBeVisible();
await dialog.getByRole('button', { name: 'Upload and Process' }).click();
await expect(page.getByText('Your bulk send has been initiated').first()).toBeVisible();
// The bulk send runs as a background job, so poll for the created document.
await expect
.poll(
async () =>
await prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
teamId: team.id,
recipients: {
some: {
email: uniqueRecipientEmail,
},
},
},
}),
{ timeout: 30_000 },
)
.toBe(1);
});
@@ -31,18 +31,21 @@ export function useAnalytics() {
};
/**
* Capture an analytic event.
* Capture an exception event.
*
* @param error The error to capture.
* @param properties Properties to attach to the event.
* @param properties Properties to attach to the event, such as `source`, `location`,
* `recipientId` or `envelopeId`. Never attach recipient tokens.
*/
const captureException = (error: Error, properties?: Record<string, unknown>) => {
const captureException = (error: unknown, properties?: Record<string, unknown>) => {
if (!isPostHogEnabled) {
return;
}
const errorToCapture = error instanceof Error ? error : new Error(String(error));
void getPosthog().then(({ default: posthog }) => {
posthog.captureException(error, properties);
posthog.captureException(errorToCapture, properties);
});
};
@@ -20,6 +20,7 @@ import { useSearchParams } from 'react-router';
import type { TDocumentEmailSettings } from '../../types/document-email';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useAnalytics } from '../hooks/use-analytics';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEditorFields } from '../hooks/use-editor-fields';
import { useEditorRecipients } from '../hooks/use-editor-recipients';
@@ -104,6 +105,7 @@ export const EnvelopeEditorProvider = ({
}: EnvelopeEditorProviderProps) => {
const { t } = useLingui();
const { toast } = useToast();
const analytics = useAnalytics();
const [_searchParams, setSearchParams] = useSearchParams();
@@ -241,6 +243,12 @@ export const EnvelopeEditorProvider = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: isEmbedded ? 'embed' : 'editor',
location: 'autosave_recipients',
envelopeId: envelope.id,
});
setAutosaveError(true);
toast({
@@ -304,6 +312,12 @@ export const EnvelopeEditorProvider = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: isEmbedded ? 'embed' : 'editor',
location: 'autosave_fields',
envelopeId: envelope.id,
});
setAutosaveError(true);
toast({
@@ -359,6 +373,12 @@ export const EnvelopeEditorProvider = ({
} catch (err) {
console.error(err);
analytics.captureException(err, {
source: isEmbedded ? 'embed' : 'editor',
location: 'autosave_meta',
envelopeId: envelope.id,
});
setAutosaveError(true);
toast({
+2
View File
@@ -93,6 +93,8 @@ export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYW
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
export const NEXT_PRIVATE_SIGNING_TRANSPORT = () => env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local';
/**
* Whether this Documenso instance is running in CSC (Cloud Signature Consortium) mode.
*
@@ -93,7 +93,7 @@ export const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDe
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Waiting for others to complete signing.`),
subject: i18n._(msg`Waiting for others to complete signing`),
html,
text,
});
@@ -2,12 +2,10 @@ import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-comp
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { zEmail } from '@documenso/lib/utils/zod';
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/macro';
import { parse } from 'csv-parse/sync';
import { createElement } from 'react';
import { z } from 'zod';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -17,14 +15,6 @@ import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefinition; io: JobRunIO }) => {
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
@@ -41,25 +31,21 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
throw new Error('Template not found');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = parse<any>(csvContent, { columns: true, skip_empty_lines: true });
if (rows.length > 100) {
throw new Error('Maximum 100 rows allowed per upload');
}
const { recipients } = template;
// Validate CSV structure
const csvHeaders = Object.keys(rows[0]);
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
// The CSV is validated upfront when the bulk send is uploaded, this acts as
// a final safeguard prior to processing.
const csvValidationResult = validateBulkSendCsv({
csvContent,
recipientCount: recipients.length,
});
for (const header of requiredHeaders) {
if (!csvHeaders.includes(header)) {
throw new Error(`Missing required column: ${header}`);
}
if (!csvValidationResult.success) {
throw new Error(`Bulk send CSV failed validation: ${JSON.stringify(csvValidationResult.error)}`);
}
const rows = csvValidationResult.data;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -79,22 +65,6 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
// Process each row
for (const [rowIndex, row] of rows.entries()) {
try {
for (const [recipientIndex] of recipients.entries()) {
const nameKey = `recipient_${recipientIndex + 1}_name`;
const emailKey = `recipient_${recipientIndex + 1}_email`;
const parsed = ZRecipientRowSchema.safeParse({
name: row[nameKey],
email: row[emailKey],
});
if (!parsed.success) {
throw new Error(
`Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
);
}
}
const envelope = await io.runTask(`create-document-${rowIndex}`, async () => {
return await createDocumentFromTemplate({
id: {
@@ -10,7 +10,13 @@ export interface AdminFindDocumentsOptions {
perPage?: number;
}
const ZPositiveIntegerSchema = z.coerce.number().int().positive();
const MAX_POSTGRES_INT = 2147483647;
/**
* IDs are Postgres int4 columns: values above the range can never be valid
* IDs and would make Prisma throw on overflow, so the schema rejects them.
*/
const ZPositiveIntegerSchema = z.coerce.number().int().positive().max(MAX_POSTGRES_INT);
const emptyResponse = {
data: [],
@@ -58,7 +64,41 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
}
}
if (query && query?.startsWith('envelope_')) {
if (query?.startsWith('recipient:')) {
const recipientQuery = query.slice('recipient:'.length).trim();
if (recipientQuery.length === 0) {
return emptyResponse;
}
// Bare numeric values are exact recipient ID lookups, consistent with the
// user: and team: prefixes. Oversized numbers cannot be IDs and fall back
// to the text search, mirroring the admin global search.
const parsedRecipientId = ZPositiveIntegerSchema.safeParse(recipientQuery);
if (/^\d+$/.test(recipientQuery) && parsedRecipientId.success) {
termFilters = {
recipients: {
some: {
id: parsedRecipientId.data,
},
},
};
} else {
termFilters = {
recipients: {
some: {
OR: [
{ email: { contains: recipientQuery, mode: 'insensitive' } },
{ name: { contains: recipientQuery, mode: 'insensitive' } },
],
},
},
};
}
}
if (query?.startsWith('envelope_')) {
termFilters = {
id: {
equals: query,
@@ -66,7 +106,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
};
}
if (query && query?.startsWith('document_')) {
if (query?.startsWith('document_')) {
termFilters = {
secondaryId: {
equals: query,
@@ -0,0 +1,83 @@
import crypto from 'node:crypto';
import { PostHog } from 'posthog-node';
import { env } from '../../utils/env';
/**
* Server-side product analytics client.
*
* Uses the same PostHog project as the client-side integration
* (NEXT_PUBLIC_POSTHOG_KEY). Not to be confused with the self-hoster
* telemetry client which reports to a separate project.
*
* Events are sent directly to PostHog rather than through the `/ingest`
* reverse proxy used by the browser.
*/
const POSTHOG_HOST = 'https://eu.i.posthog.com';
let client: PostHog | null | undefined;
const getAnalyticsClient = () => {
if (client !== undefined) {
return client;
}
const apiKey = env('NEXT_PUBLIC_POSTHOG_KEY');
// Low volume events, flush immediately so nothing is lost on shutdown.
client = apiKey ? new PostHog(apiKey, { host: POSTHOG_HOST, flushAt: 1 }) : null;
return client;
};
type CaptureServerEventOptions = {
event: string;
/**
* The authenticated user this event belongs to, if any.
*
* Used as the distinct ID so server events line up with client-side
* identified users. Anonymous events (e.g. embed signer sessions) get a
* random distinct ID instead.
*/
userId?: number;
organisationId?: string;
teamId?: number;
properties?: Record<string, unknown>;
};
/**
* Capture a product analytics event from the server.
*
* No-ops when NEXT_PUBLIC_POSTHOG_KEY is not configured. Events are captured
* as anonymous events (no person profile processing) to keep usage costs low,
* cross-referencing is done via the `userId`, `organisationId` and `teamId`
* event properties.
*
* Never pass recipient tokens or document contents as properties.
*/
export const captureServerEvent = ({
event,
userId,
organisationId,
teamId,
properties = {},
}: CaptureServerEventOptions) => {
const posthog = getAnalyticsClient();
if (!posthog) {
return;
}
posthog.capture({
distinctId: userId ? String(userId) : `anon_${crypto.randomUUID()}`,
event,
properties: {
...properties,
userId,
organisationId,
teamId,
$process_person_profile: false,
},
});
};
+23 -13
View File
@@ -1,26 +1,36 @@
import * as fs from 'node:fs';
import { X509Certificate } from 'node:crypto';
import { env } from '@documenso/lib/utils/env';
import { createLocalSigner } from '@documenso/signing/transports/local';
export const getCertificateStatus = () => {
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
import { NEXT_PRIVATE_SIGNING_TRANSPORT } from '../../constants/app';
/**
* Whether the local P12 opens with the configured passphrase and is in date.
* Skips AIA so this stays offline. gcloud-hsm and csc always report available.
*/
export const getCertificateStatus = async () => {
const transport = NEXT_PRIVATE_SIGNING_TRANSPORT();
// Cannot inspect a remote HSM or CSC provider from this process.
if (transport === 'gcloud-hsm' || transport === 'csc') {
return { isAvailable: true };
}
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
return { isAvailable: true };
// Anything else (typo, leftover `http`) would throw at seal time.
if (transport !== 'local') {
return { isAvailable: false };
}
const defaultPath = env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
try {
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
const signer = await createLocalSigner({ buildChain: false });
const stats = fs.statSync(filePath);
const certificate = new X509Certificate(Buffer.from(signer.certificate));
return { isAvailable: stats.size > 0 };
const now = new Date();
const isWithinValidityPeriod = new Date(certificate.validFrom) <= now && now <= new Date(certificate.validTo);
return { isAvailable: isWithinValidityPeriod };
} catch {
return { isAvailable: false };
}
@@ -1,9 +1,12 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { logger } from '../../utils/logger';
import { hashString } from '../auth/hash';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
const LAST_USED_AT_UPDATE_INTERVAL = 60_000; // 1 minute
type GetApiTokenByTokenOptions = {
token: string;
@@ -96,6 +99,29 @@ export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: Get
});
}
// Only update the lastUsedAt after X amount of time has passed to reduce
// the number of writes to the database
if (!apiToken.lastUsedAt || apiToken.lastUsedAt.getTime() + LAST_USED_AT_UPDATE_INTERVAL < Date.now()) {
void prisma.apiToken
.updateMany({
where: {
id: apiToken.id,
// Optimistic guard: skip if another request beat us
lastUsedAt: apiToken.lastUsedAt,
},
data: {
lastUsedAt: new Date(),
},
})
.catch((err) => {
logger.warn({
msg: 'Failed to update API token lastUsedAt',
apiTokenId: apiToken.id,
err,
});
});
}
return {
...apiToken,
user,
@@ -22,6 +22,7 @@ export const getApiTokens = async ({ userId, teamId }: GetApiTokensOptions) => {
name: true,
createdAt: true,
expires: true,
lastUsedAt: true,
},
orderBy: {
createdAt: 'desc',
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest';
import type { TBulkSendCsvError, TValidateBulkSendCsvResult } from './validate-bulk-send-csv';
import { validateBulkSendCsv } from './validate-bulk-send-csv';
const buildCsv = (headers: string[], rows: string[][]) =>
[headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
const expectFailure = (result: TValidateBulkSendCsvResult): TBulkSendCsvError => {
if (result.success) {
throw new Error('Expected validation to fail, but it passed');
}
return result.error;
};
describe('validateBulkSendCsv', () => {
describe('valid CSVs', () => {
it('returns the parsed rows for a valid CSV', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_1_name'],
[
['alice@example.com', 'Alice'],
['bob@example.com', 'Bob'],
],
);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result).toEqual({
success: true,
data: [
{ recipient_1_email: 'alice@example.com', recipient_1_name: 'Alice' },
{ recipient_1_email: 'bob@example.com', recipient_1_name: 'Bob' },
],
});
});
it('allows an empty string email so template defaults can be used', () => {
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], [['', 'Alice']]);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
it('allows the optional name column to be omitted entirely', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
it('allows unknown extra columns', () => {
const csvContent = buildCsv(['recipient_1_email', 'unrelated_column'], [['alice@example.com', 'anything']]);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
it('validates columns for every configured recipient', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_2_email'],
[['alice@example.com', 'bob@example.com']],
);
const result = validateBulkSendCsv({ csvContent, recipientCount: 2 });
expect(result.success).toBe(true);
});
it('allows exactly the maximum number of rows', () => {
const csvContent = buildCsv(
['recipient_1_email'],
Array.from({ length: 100 }, (_, index) => [`user${index}@example.com`]),
);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
});
describe('PARSE_ERROR', () => {
it('rejects a CSV that cannot be parsed', () => {
const csvContent = 'recipient_1_email\n"unclosed quote';
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'PARSE_ERROR' });
});
it('rejects a CSV with inconsistent column counts', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_1_name'],
[['alice@example.com', 'Alice', 'unexpected-extra-value']],
);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'PARSE_ERROR' });
});
});
describe('EMPTY', () => {
it('rejects an empty file', () => {
const error = expectFailure(validateBulkSendCsv({ csvContent: '', recipientCount: 1 }));
expect(error).toEqual({ type: 'EMPTY' });
});
it('rejects a CSV containing only a header row', () => {
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], []);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'EMPTY' });
});
});
describe('ROW_LIMIT_EXCEEDED', () => {
it('rejects a CSV exceeding the default limit of 100 rows', () => {
const csvContent = buildCsv(
['recipient_1_email'],
Array.from({ length: 101 }, (_, index) => [`user${index}@example.com`]),
);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 101, maxRows: 100 });
});
it('respects a custom maxRows option', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['bob@example.com']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1, maxRows: 1 }));
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 2, maxRows: 1 });
});
});
describe('MISSING_COLUMNS', () => {
it('rejects a CSV missing a required email column', () => {
const csvContent = buildCsv(['recipient_1_name'], [['Alice']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'MISSING_COLUMNS', missingColumns: ['recipient_1_email'] });
});
it('reports every missing column', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 3 }));
expect(error).toEqual({
type: 'MISSING_COLUMNS',
missingColumns: ['recipient_2_email', 'recipient_3_email'],
});
});
});
describe('INVALID_RECIPIENTS', () => {
it('rejects a CSV containing an invalid email', () => {
const csvContent = buildCsv(['recipient_1_email'], [['not-an-email']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toMatchObject({
type: 'INVALID_RECIPIENTS',
rowErrors: [{ row: 1, column: 'recipient_1_email' }],
});
});
it('references the offending row and column', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['not-an-email']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toMatchObject({
type: 'INVALID_RECIPIENTS',
rowErrors: [{ row: 2, column: 'recipient_1_email' }],
});
});
it('aggregates errors across multiple rows and recipients', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_2_email'],
[
['not-an-email', 'bob@example.com'],
['alice@example.com', 'also-not-an-email'],
],
);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 2 }));
expect(error).toMatchObject({
type: 'INVALID_RECIPIENTS',
rowErrors: [
{ row: 1, column: 'recipient_1_email' },
{ row: 2, column: 'recipient_2_email' },
],
});
});
});
});
@@ -0,0 +1,117 @@
import { parse } from 'csv-parse/sync';
import { z } from 'zod';
import { zEmail } from '../../utils/zod';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
export type TBulkSendCsvRow = Record<string, string | undefined>;
export type TBulkSendCsvRowError = {
/**
* The 1-indexed row number the error occurred on, excluding the header row.
*/
row: number;
/**
* The column the error occurred in, such as `recipient_1_email`.
*/
column: string;
message: string;
};
export type TBulkSendCsvError =
| { type: 'PARSE_ERROR' }
| { type: 'EMPTY' }
| { type: 'ROW_LIMIT_EXCEEDED'; rowCount: number; maxRows: number }
| { type: 'MISSING_COLUMNS'; missingColumns: string[] }
| { type: 'INVALID_RECIPIENTS'; rowErrors: TBulkSendCsvRowError[] };
export type TValidateBulkSendCsvResult =
| { success: true; data: TBulkSendCsvRow[] }
| { success: false; error: TBulkSendCsvError };
export type ValidateBulkSendCsvOptions = {
csvContent: string;
/**
* The number of recipients configured on the template, used to derive the
* required `recipient_N_email` columns.
*/
recipientCount: number;
maxRows?: number;
};
/**
* Validate the CSV provided for a template bulk send.
*
* Returns a discriminated union so callers can surface structured error
* details, such as which rows contain invalid recipients.
*/
export const validateBulkSendCsv = ({
csvContent,
recipientCount,
maxRows = 100,
}: ValidateBulkSendCsvOptions): TValidateBulkSendCsvResult => {
let rows: TBulkSendCsvRow[];
try {
rows = parse(csvContent, { columns: true, skip_empty_lines: true });
} catch {
return { success: false, error: { type: 'PARSE_ERROR' } };
}
if (rows.length === 0) {
return { success: false, error: { type: 'EMPTY' } };
}
if (rows.length > maxRows) {
return { success: false, error: { type: 'ROW_LIMIT_EXCEEDED', rowCount: rows.length, maxRows } };
}
const csvHeaders = Object.keys(rows[0]);
const requiredHeaders = Array.from({ length: recipientCount }, (_, index) => `recipient_${index + 1}_email`);
const missingColumns = requiredHeaders.filter((header) => !csvHeaders.includes(header));
if (missingColumns.length > 0) {
return { success: false, error: { type: 'MISSING_COLUMNS', missingColumns } };
}
const rowErrors: TBulkSendCsvRowError[] = [];
for (const [rowIndex, row] of rows.entries()) {
for (let recipientIndex = 0; recipientIndex < recipientCount; recipientIndex += 1) {
const nameKey = `recipient_${recipientIndex + 1}_name`;
const emailKey = `recipient_${recipientIndex + 1}_email`;
const parsed = ZRecipientRowSchema.safeParse({
name: row[nameKey],
email: row[emailKey],
});
if (!parsed.success) {
rowErrors.push({
row: rowIndex + 1,
column: emailKey,
message: parsed.error.issues?.[0]?.message ?? 'Invalid value',
});
}
}
}
if (rowErrors.length > 0) {
return { success: false, error: { type: 'INVALID_RECIPIENTS', rowErrors } };
}
return { success: true, data: rows };
};
@@ -35,33 +35,6 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
},
});
// Todo: (RR7) Migrate to use this after RR7.
// Note: If we actually ever proceed with this, there are multiple
// locations where we will need to update this.
// const user = await prisma.$transaction(async (tx) => {
// const user = await tx.user.create({
// data: {
// name,
// email: email.toLowerCase(),
// password: hashedPassword, // Todo: (RR7) Drop password.
// signature,
// },
// });
// await tx.account.create({
// data: {
// userId: user.id,
// type: 'emailPassword', // Todo: (RR7)
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
// providerAccountId: user.id.toString(),
// password: hashedPassword,
// },
// });
// return user;
// });
// Not used at the moment, uncomment if required.
await onCreateUserHook(user).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
@@ -81,13 +81,20 @@ describe('isPrivateUrl', () => {
expect(isPrivateUrl('http://[fd12::1]')).toBe(true);
});
it('should not catch IPv4-mapped IPv6 in URL form (URL parser normalizes to hex)', () => {
// new URL() normalizes "::ffff:127.0.0.1" to "::ffff:7f00:1" which none
// of the checks handle. This is fine because dns.lookup never returns
// IPv4-mapped addresses — it returns plain IPv4 (family: 4) instead.
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(false);
it('should detect private IPv4-mapped IPv6 addresses (URL parser normalizes to hex)', () => {
// new URL() normalizes "::ffff:127.0.0.1" to the hex form "::ffff:7f00:1",
// so the embedded IPv4 must be decoded and re-checked. Otherwise a literal
// host such as http://[::ffff:127.0.0.1] bypasses every dotted-decimal
// check above (SSRF, see #2901).
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(true);
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(true);
expect(isPrivateUrl('http://[::ffff:192.168.0.1]')).toBe(true);
expect(isPrivateUrl('http://[::ffff:169.254.169.254]')).toBe(true);
});
it('should still allow public IPv4-mapped IPv6 addresses', () => {
expect(isPrivateUrl('http://[::ffff:8.8.8.8]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:1.1.1.1]')).toBe(false);
});
});
@@ -69,11 +69,25 @@ export const isPrivateUrl = (url: string): boolean => {
}
}
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
// IPv4-mapped IPv6, dotted form (e.g. ::ffff:127.0.0.1)
const v4MappedDotted = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (v4Mapped) {
return isPrivateUrl(`http://${v4Mapped[1]}`);
if (v4MappedDotted) {
return isPrivateUrl(`http://${v4MappedDotted[1]}`);
}
// IPv4-mapped IPv6, hex form (e.g. ::ffff:7f00:1). `new URL()` normalizes the
// dotted form above to this, so it must be decoded to the embedded IPv4 as
// well - otherwise a literal host such as `http://[::ffff:127.0.0.1]` slips
// through every dotted-decimal check above (SSRF, see #2901).
const v4MappedHex = normalizedHost.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (v4MappedHex) {
const high = parseInt(v4MappedHex[1], 16);
const low = parseInt(v4MappedHex[2], 16);
const ipv4 = [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.');
return isPrivateUrl(`http://${ipv4}`);
}
return false;
+209 -25
View File
@@ -312,6 +312,12 @@ msgstr "{0}-Feld"
msgid "{0} has invited you to {recipientActionVerb} the document \"{1}\"."
msgstr "{0} hat Sie eingeladen, das Dokument \"{1}\" {recipientActionVerb}."
#. placeholder {0}: organisation.name
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
msgid "{0} has invited you to sign this document."
msgstr ""
#. placeholder {0}: team.name
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
msgid "{0} invited you to {recipientActionVerb} a document"
@@ -434,6 +440,10 @@ msgstr "{inviterName} im Namen von \"{teamName}\" hat dich eingeladen, {0}<0/>\"
msgid "{inviterName} on behalf of \"{teamName}\" has invited you to {action} {documentName}"
msgstr "{inviterName} im Namen von \"{teamName}\" hat Sie eingeladen, das Dokument {documentName} {action}"
#: apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx
msgid "{MAX_BULK_DOWNLOAD_ENVELOPES, plural, one {You can download up to # document at a time. Deselect some documents to continue.} other {You can download up to # documents at a time. Deselect some documents to continue.}}"
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "{MAXIMUM_PASSKEYS, plural, one {You cannot have more than # passkey.} other {You cannot have more than # passkeys.}}"
msgstr "{MAXIMUM_PASSKEYS, plural, one {Sie können nicht mehr als # Zugangsschlüssel haben.} other {Sie können nicht mehr als # Zugangsschlüssel haben.}}"
@@ -514,7 +524,11 @@ msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} hat Sie eingeladen, {action} {documentName}"
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "{totalVisibleCount} items"
msgid "{totalVisibleCount, plural, one {# item} other {# items}}"
msgstr ""
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "{totalVisibleCount, plural, one {# result} other {# results}}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
@@ -1221,6 +1235,10 @@ msgstr ""
msgid "Account unlinked"
msgstr "Konto entkoppelt"
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Accounts are automatically added to your organisation on sign-in"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "Acknowledgment"
msgstr "Bestätigung"
@@ -1818,10 +1836,6 @@ msgstr "Beim automatischen Signieren des Dokuments ist ein Fehler aufgetreten, e
msgid "An error occurred while cancelling the documents."
msgstr "Beim Stornieren der Dokumente ist ein Fehler aufgetreten."
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "An error occurred while completing the document. Please try again."
msgstr "Beim Abschließen des Dokuments ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut."
#: apps/remix/app/utils/toast-error-messages.ts
msgid "An error occurred while creating document from template."
msgstr "Ein Fehler ist aufgetreten, während das Dokument aus der Vorlage erstellt wurde."
@@ -2455,6 +2469,10 @@ msgstr "Eckenradius-Größe in REM-Einheiten (z. B. 0.5rem)."
msgid "Bottom"
msgstr "Unten"
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
msgid "Brand accent"
msgstr ""
#: apps/remix/app/components/forms/branding-preferences-form.tsx
msgid "Brand Colours"
msgstr "Markenfarben"
@@ -2988,6 +3006,14 @@ msgstr "Durch Kommas getrennte Liste von E-Mail-Domains, die von der Registrieru
msgid "Communication"
msgstr "Kommunikation"
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
msgid "Company details"
msgstr ""
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
msgid "Company details and website in email footers"
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
msgid "Compare all plans and features in detail"
msgstr "Vergleichen Sie alle Pläne und Funktionen im Detail"
@@ -3174,6 +3200,11 @@ msgstr "Zustimmung zu elektronischen Transaktionen"
msgid "Contact Information"
msgstr "Kontaktinformationen"
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Contact Sales"
msgstr ""
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
msgid "Contact sales here"
msgstr "Vertrieb hier kontaktieren"
@@ -3182,6 +3213,10 @@ msgstr "Vertrieb hier kontaktieren"
msgid "Contact us"
msgstr "Kontaktieren Sie uns"
#: apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx
msgid "Contact your organisation owner to upgrade plans."
msgstr ""
#: apps/remix/app/components/general/admin-site-banner-section.tsx
msgid "Content"
msgstr "Inhalt"
@@ -3234,6 +3269,10 @@ msgstr "Fahre fort, indem du das Dokument ansiehst."
msgid "Continue to login"
msgstr "Weiter zum Login"
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Continue with SSO"
msgstr ""
#: apps/remix/app/components/forms/reminder-preferences-form.tsx
msgid "Controls how long recipients have to complete signing before the document expires. After expiration, recipients can no longer sign the document."
msgstr "Steuert, wie lange Empfänger Zeit haben, die Signatur abzuschließen, bevor das Dokument abläuft. Nach dem Ablauf können Empfänger das Dokument nicht mehr unterschreiben."
@@ -4335,6 +4374,10 @@ msgstr "Die Authentifizierung für den Dokumentenzugriff wurde aktualisiert"
msgid "Document All"
msgstr "Dokument Alle"
#: apps/remix/app/utils/toast-error-messages.ts
msgid "Document already completed"
msgstr ""
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
msgid "Document already signed"
msgstr ""
@@ -4345,6 +4388,7 @@ msgstr "Dokument genehmigt"
#: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
#: apps/remix/app/utils/toast-error-messages.ts
msgid "Document cancelled"
msgstr "Dokument storniert"
@@ -4519,10 +4563,18 @@ msgctxt "Audit log format"
msgid "Document moved to team"
msgstr "Dokument ins Team verschoben"
#: apps/remix/app/utils/toast-error-messages.ts
msgid "Document no longer available"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Document no longer available to sign"
msgstr "Dokument steht nicht mehr zur Unterschrift zur Verfügung"
#: apps/remix/app/utils/toast-error-messages.ts
msgid "Document not ready"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgctxt "Audit log format"
msgid "Document opened"
@@ -4550,6 +4602,7 @@ msgstr "Dokumentpräferenzen aktualisiert"
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
#: apps/remix/app/components/general/document/document-status.tsx
#: apps/remix/app/utils/toast-error-messages.ts
msgid "Document rejected"
msgstr "Dokument abgelehnt"
@@ -4978,6 +5031,10 @@ msgstr "Z. B. 100"
msgid "e.g. Resend (free plans)"
msgstr "z. B. Resend (kostenlose Tarife)"
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
msgid "Easy DNS setup with auto-generated DKIM and SPF records"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-button.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/admin-dashboard-users-table.tsx
@@ -5496,6 +5553,8 @@ msgid "Enter your text here"
msgstr "Geben Sie hier Ihren Text ein"
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Enterprise"
msgstr "Unternehmen"
@@ -5562,7 +5621,6 @@ msgstr "Umschlag aktualisiert"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
#: apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx
@@ -5573,7 +5631,6 @@ msgstr "Umschlag aktualisiert"
#: apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-email-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-email-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-name-field.tsx
@@ -5743,10 +5800,6 @@ msgstr "Kontaktdaten werden extrahiert"
msgid "Failed"
msgstr "Fehlgeschlagen"
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
msgid "Failed to complete the document. Please try again."
msgstr "Das Dokument konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut."
#: apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx
msgid "Failed to create document. Please try again."
msgstr "Dokument konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
@@ -6147,6 +6200,10 @@ msgstr "Links generieren"
msgid "German"
msgstr "Deutsch"
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Give your members a dedicated single sign-on portal. The SSO portal is available on the Enterprise plan."
msgstr ""
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "Global"
@@ -6379,6 +6436,10 @@ msgstr "Hallo, {userName} <0>({userEmail})</0>"
msgid "Hide"
msgstr "Ausblenden"
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
msgid "Hide field"
msgstr ""
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "Hide license key"
msgstr "Lizenzschlüssel ausblenden"
@@ -6789,6 +6850,10 @@ msgstr "Es ist momentan nicht Ihre Runde zum Unterschreiben. Bitte schauen Sie b
msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document."
msgstr "Es ist derzeit nicht deine Reihe zu unterschreiben. Du erhältst eine E-Mail mit Anweisungen, sobald es deine Reihe ist, das Dokument zu unterschreiben."
#: apps/remix/app/utils/toast-error-messages.ts
msgid "It's not your turn to sign yet"
msgstr ""
#: packages/lib/constants/i18n.ts
msgid "Italian"
msgstr "Italienisch"
@@ -6907,6 +6972,10 @@ msgstr "Zuletzt aktualisiert am"
msgid "Last used"
msgstr "Zuletzt verwendet"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx
msgid "Last Used"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Last Verified"
@@ -7105,6 +7174,7 @@ msgstr "Verwalten"
msgid "Manage {0}'s profile"
msgstr "Verwalten Sie das Profil von {0}"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Manage a custom SSO login portal for your organisation."
msgstr "Verwalten Sie ein benutzerdefiniertes SSO-Login-Portal für Ihre Organisation."
@@ -7531,6 +7601,10 @@ msgstr "Name ist erforderlich"
msgid "Name Settings"
msgstr "Einstellungen für Namen"
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
msgid "Named senders with defaults per team, template or document"
msgstr ""
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "Navigate"
msgstr ""
@@ -7558,6 +7632,7 @@ msgstr "Muss sehen"
#: apps/remix/app/components/dialogs/token-create-dialog.tsx
#: apps/remix/app/components/tables/settings-security-passkey-table.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx
msgid "Never"
msgstr "Niemals"
@@ -7944,6 +8019,10 @@ msgstr "Sobald Ihre Vorlage eingerichtet ist, teilen Sie den Link überall, wo S
msgid "One of the documents in the current bundle has a signing role that is not compatible with the current signing experience."
msgstr "Eines der Dokumente im aktuellen Paket hat eine Signaturrolle, die mit der aktuellen Signiererfahrung nicht kompatibel ist."
#: apps/remix/app/utils/toast-error-messages.ts
msgid "One or more of your required fields have not been saved. Please refresh the page, complete any empty required fields, and try again."
msgstr ""
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Only admins can access and view the document"
msgstr "Nur Administratoren können auf das Dokument zugreifen und es anzeigen"
@@ -8127,6 +8206,7 @@ msgstr "Organisationsrolle"
msgid "Organisation Settings"
msgstr "Organisationseinstellungen"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Organisation SSO Portal"
@@ -8673,6 +8753,11 @@ msgstr "Bitte wählen Sie mindestens ein Mitglied aus, das zum Team hinzugefügt
msgid "Please select how you'd like to receive your verification code."
msgstr "Bitte wählen Sie, wie Sie Ihren Verifizierungscode erhalten möchten."
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
msgid "Please sign: Example.pdf"
msgstr ""
#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx
msgid "Please try a different domain."
msgstr "Bitte versuchen Sie eine andere Domain."
@@ -8859,6 +8944,10 @@ msgstr "Öffentliche Vorlage"
msgid "Public templates are connected to your public profile. Any modifications to public templates will also appear in your public profile."
msgstr "Öffentliche Vorlagen sind mit Ihrem öffentlichen Profil verbunden. Änderungen an öffentlichen Vorlagen erscheinen auch in Ihrem öffentlichen Profil."
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
msgid "Put your own brand on every document you send. Branding is available on the Teams plan and above."
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx
msgid "Quick Actions"
msgstr "Schnellaktionen"
@@ -9187,6 +9276,10 @@ msgstr "Weiterleitung"
msgid "Redirecting to {0}..."
msgstr ""
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Redirecting to your identity provider"
msgstr ""
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Registration Successful"
@@ -9555,6 +9648,10 @@ msgstr "Antwort-Header"
msgid "Rest assured, your document is strictly confidential and will never be shared. Only your signing experience will be highlighted. Share your personalized signing card to showcase your signature!"
msgstr "Seien Sie versichert, Ihr Dokument ist streng vertraulich und wird niemals geteilt. Nur Ihre Unterzeichnungserfahrung wird hervorgehoben. Teilen Sie Ihre personalisierte Unterschriftkarte, um Ihre Unterschrift zu präsentieren!"
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Restrict sign-ins by email domain and choose the default role"
msgstr ""
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
@@ -9716,8 +9813,8 @@ msgid "Search by claim ID or name"
msgstr "Suche nach Anspruchs-ID oder Name"
#: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx
msgid "Search by document title, team:123 or user:123"
msgstr "Nach Dokumenttitel, team:123 oder user:123 suchen"
msgid "Search by document title, recipient:123, team:123 or user:123"
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx
msgid "Search by domain or organisation name"
@@ -10071,6 +10168,10 @@ msgstr "Dokument senden"
msgid "Send Document"
msgstr "Dokument senden"
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
msgid "Send documents from your own domain. Email domains are available on the Enterprise plan."
msgstr ""
#: packages/email/templates/confirm-team-email.tsx
msgid "Send documents on behalf of the team using the email address"
msgstr "Dokumente im Namen des Teams über die E-Mail-Adresse senden"
@@ -10079,6 +10180,10 @@ msgstr "Dokumente im Namen des Teams über die E-Mail-Adresse senden"
msgid "Send documents to recipients immediately"
msgstr "Dokumente sofort an Empfänger senden"
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
msgid "Send emails to recipients from your domain"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx
msgid "Send Envelope"
msgstr "Umschlag senden"
@@ -10125,6 +10230,14 @@ msgstr "Absender"
msgid "Sender reported"
msgstr "Absender gemeldet"
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
msgid "Sending from app.documenso.com"
msgstr ""
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
msgid "Sending from your domain"
msgstr ""
#: apps/remix/app/components/forms/forgot-password.tsx
msgid "Sending Reset Email..."
msgstr "Zurücksetzungs-E-Mail wird gesendet..."
@@ -10145,6 +10258,10 @@ msgstr "Gesendet"
msgid "Sent this period"
msgstr ""
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
msgid "Separate branding per team"
msgstr ""
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
msgid "Session revoked"
msgstr "Sitzung widerrufen"
@@ -10250,6 +10367,7 @@ msgstr "Nutzung mit Kontingenten anzeigen"
#: apps/remix/app/components/general/document-signing/document-signing-name-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
#: apps/remix/app/components/general/document/document-page-view-button.tsx
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
#: apps/remix/app/components/general/user-profile-skeleton.tsx
#: apps/remix/app/components/general/user-profile-timur.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
@@ -10421,6 +10539,10 @@ msgctxt "Signed document (adjective)"
msgid "Signed"
msgstr "Signiert"
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Signed in"
msgstr ""
#: apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx
msgid "Signer"
msgstr "Unterzeichner"
@@ -10485,6 +10607,10 @@ msgstr "Unterzeichne für"
msgid "Signing in..."
msgstr "Anmeldung..."
#: apps/remix/app/utils/toast-error-messages.ts
msgid "Signing link expired"
msgstr ""
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
msgid "Signing Links"
@@ -10537,6 +10663,10 @@ msgstr "Die Registrierung ist derzeit deaktiviert oder für Ihre E-Mail-Domain n
msgid "Since {0}"
msgstr "Seit {0}"
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Single sign-on"
msgstr ""
#: apps/remix/app/components/general/admin-site-banner-section.tsx
msgid "Site Banner"
msgstr "Website Banner"
@@ -10556,6 +10686,10 @@ msgstr "Überspringen"
msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected."
msgstr ""
#: apps/remix/app/utils/toast-error-messages.ts
msgid "Some fields were not saved"
msgstr ""
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "Some searches failed — results may be incomplete."
msgstr ""
@@ -10577,7 +10711,6 @@ msgstr "Einige Unterzeichner haben noch kein Unterschriftsfeld zugewiesen bekomm
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/billing-plans.tsx
@@ -10587,8 +10720,6 @@ msgstr "Einige Unterzeichner haben noch kein Unterschriftsfeld zugewiesen bekomm
#: apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx
#: apps/remix/app/components/general/document/document-audit-log-download-button.tsx
#: apps/remix/app/components/general/document/document-certificate-download-button.tsx
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
#: apps/remix/app/components/general/organisations/organisation-billing-portal-button.tsx
#: apps/remix/app/components/general/organisations/organisation-invitations.tsx
@@ -10611,6 +10742,7 @@ msgstr "Einige Unterzeichner haben noch kein Unterschriftsfeld zugewiesen bekomm
#: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx
#: apps/remix/app/utils/toast-error-messages.ts
#: apps/remix/app/utils/toast-error-messages.ts
#: apps/remix/app/utils/toast-error-messages.ts
#: packages/ui/components/document/document-share-button.tsx
msgid "Something went wrong"
msgstr "Etwas ist schief gelaufen"
@@ -11137,6 +11269,7 @@ 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/general/settings-upsell/branding-upsell.tsx
#: apps/remix/app/components/general/use-admin-search-categories.ts
#: apps/remix/app/components/tables/admin-organisation-overview-table.tsx
#: apps/remix/app/components/tables/organisation-insights-table.tsx
@@ -11892,6 +12025,10 @@ msgstr ""
msgid "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."
msgstr ""
#: apps/remix/app/utils/toast-error-messages.ts
msgid "This document can no longer be signed. It may have been removed by the sender, or your signing access may have been revoked. Please contact the sender for a new signing link."
msgstr ""
#: packages/email/template-components/template-document-super-delete.tsx
msgid "This document can not be recovered, if you would like to dispute the reason for future documents please contact support."
msgstr "Dieses Dokument kann nicht wiederhergestellt werden. Wenn du den Grund für zukünftige Dokumente anfechten möchtest, kontaktiere bitte den Support."
@@ -11920,6 +12057,10 @@ msgstr "Dieses Dokument konnte derzeit nicht dupliziert werden. Bitte versuche e
msgid "This document could not be saved as a template at this time. Please try again."
msgstr "Dieses Dokument konnte derzeit nicht als Vorlage gespeichert werden. Bitte versuchen Sie es erneut."
#: apps/remix/app/utils/toast-error-messages.ts
msgid "This document has already been completed and no further signatures can be added."
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx
#: packages/ui/primitives/recipient-selector.tsx
msgid "This document has already been sent to this recipient. You can no longer edit this recipient."
@@ -11938,14 +12079,26 @@ msgstr "Dieses Dokument wurde vom Eigentümer storniert und steht anderen nicht
msgid "This document has been cancelled by the owner."
msgstr "Dieses Dokument wurde vom Eigentümer storniert."
#: apps/remix/app/utils/toast-error-messages.ts
msgid "This document has been cancelled by the sender and can no longer be signed. Please contact the sender if you believe this is a mistake."
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
msgid "This document has been rejected by a recipient"
msgstr "Dieses Dokument wurde von einem Empfänger abgelehnt"
#: apps/remix/app/utils/toast-error-messages.ts
msgid "This document has been rejected by a recipient and can no longer be signed."
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx
msgid "This document has been signed by all recipients"
msgstr "Dieses Dokument wurde von allen Empfängern unterschrieben"
#: apps/remix/app/utils/toast-error-messages.ts
msgid "This document has not been sent for signing yet. Please wait for the sender to send it before signing."
msgstr ""
#: apps/remix/app/utils/toast-error-messages.ts
msgid "This document has too many recipients. Please remove some recipients or contact support if you need more."
msgstr "Dieses Dokument hat zu viele Empfänger. Bitte entfernen Sie einige Empfänger oder kontaktieren Sie den Support, wenn Sie mehr benötigen."
@@ -11958,6 +12111,10 @@ msgstr "Dieses Dokument ist in Ihrem Documenso-Konto verfügbar. Dort können Si
msgid "This document is currently a draft and has not been sent"
msgstr "Dieses Dokument ist momentan ein Entwurf und wurde nicht gesendet"
#: apps/remix/app/utils/toast-error-messages.ts
msgid "This document is signed in a set order and other recipients must sign before you. You will receive an email when it is your turn."
msgstr ""
#: apps/remix/app/components/general/legacy-field-warning-popover.tsx
msgid "This document is using legacy field insertion, we recommend using the new field insertion method for more accurate results."
msgstr "Dieses Dokument verwendet Altfeld-Integration, wir empfehlen die Verwendung der neuen Methode für genauere Ergebnisse."
@@ -12673,6 +12830,18 @@ msgstr "Unbegrenzte Dokumente, API und mehr"
msgid "Unlink"
msgstr "Verknüpfung aufheben"
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
msgid "Unlock Branding Preferences"
msgstr ""
#: apps/remix/app/components/general/settings-upsell/email-domains-upsell.tsx
msgid "Unlock Email Domains"
msgstr ""
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Unlock the Organisation SSO Portal"
msgstr ""
#: apps/remix/app/components/general/folder/folder-card.tsx
msgid "Unpin"
msgstr "Lösen"
@@ -12846,6 +13015,10 @@ msgstr "Upgrade"
msgid "Upgrade <0>{0}</0> to {planName}"
msgstr "<0>{0}</0> auf {planName} aktualisieren"
#: apps/remix/app/components/general/settings-upsell/settings-upsell-card.tsx
msgid "Upgrade Plan"
msgstr ""
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
msgid "Upgrade your plan to upload more documents"
msgstr "Aktualisieren Sie Ihren Tarif, um mehr Dokumente hochzuladen"
@@ -13332,8 +13505,8 @@ msgid "Waiting for others"
msgstr "Warten auf andere"
#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts
msgid "Waiting for others to complete signing."
msgstr "Warten auf andere, um die Unterzeichnung abzuschließen."
msgid "Waiting for others to complete signing"
msgstr ""
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Waiting for others to sign"
@@ -13662,9 +13835,7 @@ msgstr "Wir konnten Ihr öffentliches Profil nicht auf öffentlich setzen. Bitte
msgid "We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again."
msgstr "Wir konnten die Zwei-Faktor-Authentifizierung für Ihr Konto nicht einrichten. Bitte stellen Sie sicher, dass Sie den Code korrekt eingegeben haben und versuchen Sie es erneut."
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
#: apps/remix/app/utils/toast-error-messages.ts
#: apps/remix/app/utils/toast-error-messages.ts
msgid "We were unable to submit this document at this time. Please try again later."
msgstr "Wir konnten dieses Dokument zurzeit nicht einreichen. Bitte versuchen Sie es später erneut."
@@ -13830,6 +14001,11 @@ msgstr "Willkommen zurück, wir freuen uns, Sie zu haben."
msgid "Welcome back! Here's an overview of your account."
msgstr "Willkommen zurück! Hier ist ein Überblick über Ihr Konto."
#. placeholder {0}: organisation.name
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Welcome to {0}"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
msgid "Welcome to {organisationName}"
msgstr "Willkommen bei {organisationName}"
@@ -13907,6 +14083,10 @@ msgstr "Zustimmung widerrufen"
msgid "Within limit"
msgstr ""
#: apps/remix/app/components/general/settings-upsell/sso-portal-upsell.tsx
msgid "Works with any OIDC provider — Okta, Entra ID, Google and more"
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"
@@ -14157,10 +14337,6 @@ msgstr "Sie können wählen, ob das Profil für die öffentliche Ansicht aktivie
msgid "You can copy and share these links to recipients so they can action the document."
msgstr "Sie können diese Links kopieren und mit den Empfängern teilen, damit sie das Dokument bearbeiten können."
#: apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx
msgid "You can download up to {MAX_BULK_DOWNLOAD_ENVELOPES} documents at a time. Deselect some documents to continue."
msgstr ""
#: apps/remix/app/components/general/teams/team-inherit-member-alert.tsx
msgid "You can enable access to allow all organisation members to access this team by default."
msgstr "Sie können den Zugriff so aktivieren, dass alle Organisationsmitglieder standardmäßig Zugriff auf dieses Team haben."
@@ -14924,6 +15100,10 @@ msgstr "Ihr Kuvert wurde erfolgreich verteilt."
msgid "Your envelope has been resent successfully."
msgstr "Ihr Kuvert wurde erfolgreich erneut gesendet."
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
msgid "Your logo on signing pages and emails"
msgstr ""
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "Your Name"
msgstr "Ihr Name"
@@ -15057,6 +15237,10 @@ msgstr "Ihre Unterzeichnungsautorisierung ist abgelaufen, bevor die Signatur ang
msgid "Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or signing provider for assistance."
msgstr "Ihr Signaturzertifikat ist ungültig, abgelaufen oder es fehlt ein erforderlicher Schlüssel. Wenden Sie sich an Ihren Administrator oder Unterzeichnungsanbieter, um Unterstützung zu erhalten."
#: apps/remix/app/utils/toast-error-messages.ts
msgid "Your signing link has expired. Please contact the sender to request a new one."
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "Your signing provider authentication failed"
msgstr "Ihre Authentifizierung beim Unterzeichnungsanbieter ist fehlgeschlagen"

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