mirror of
https://github.com/documenso/documenso.git
synced 2026-09-30 16:54:34 +10:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
389390c884 | ||
|
|
c5acba538e | ||
|
|
f5ab8a8ae3 | ||
|
|
3b20c2f960 | ||
|
|
5e8a434141 | ||
|
|
6a8bb4be04 | ||
|
|
2cac63a000 | ||
|
|
4aa3583e89 | ||
|
|
30a6b19b47 | ||
|
|
dabb7b7a0d | ||
|
|
cbb1cf7bef | ||
|
|
3ec877a68b | ||
|
|
bf406aa943 | ||
|
|
a2745ed3c2 | ||
|
|
937e9376d8 | ||
|
|
eba71d9be4 | ||
|
|
5082b475a0 | ||
|
|
4285c88f12 | ||
|
|
ae4b56dd88 | ||
|
|
ee8360585b | ||
|
|
9dc83bdb06 | ||
|
|
e532843aa6 | ||
|
|
dd08da7ea7 | ||
|
|
10f2b80025 | ||
|
|
73d58bdf31 | ||
|
|
1de57a9e43 | ||
|
|
7f11c163fe | ||
|
|
5beb57dbf0 | ||
|
|
ead3104896 | ||
|
|
75330166cc |
@@ -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.
|
||||
@@ -2,7 +2,7 @@ name: 'Setup node'
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v22.x
|
||||
default: v24.x
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
|
||||
#### Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||
|
||||
#### Koyeb
|
||||
|
||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||
|
||||
#### Elestio
|
||||
|
||||
[](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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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),
|
||||
|
||||
+23
@@ -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.`),
|
||||
|
||||
+7
@@ -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.`),
|
||||
|
||||
+16
@@ -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.`),
|
||||
|
||||
+16
@@ -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.`,
|
||||
|
||||
+16
@@ -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.
|
||||
|
||||
+15
@@ -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.`,
|
||||
|
||||
+14
-6
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
Generated
+35
-14
@@ -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
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user