mirror of
https://github.com/documenso/documenso.git
synced 2026-10-01 01:04:38 +10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cc6007c82 |
@@ -1,61 +0,0 @@
|
||||
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: v24.x
|
||||
default: v22.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 (v24 or above)
|
||||
- Node.js (v22 or above)
|
||||
- Postgres SQL Database
|
||||
- Docker (optional)
|
||||
|
||||
@@ -186,37 +186,21 @@ For full instructions, requirements, and configuration details, see the [Self Ho
|
||||
|
||||
### One-Click Deploys
|
||||
|
||||
> [!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.
|
||||
#### Railway
|
||||
|
||||
<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>
|
||||
[](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)
|
||||
|
||||
## 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_xxxxxxxxxxxxxxxx" \
|
||||
-H "Authorization: api_team_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_xxxxxxxxxxxxxxxx"
|
||||
-H "Authorization: api_team_xxxxxxxxxxxxxxxx"
|
||||
|
||||
# Filter by status
|
||||
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
|
||||
-H "Authorization: api_xxxxxxxxxxxxxxxx"
|
||||
-H "Authorization: api_team_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_xxxxxxxxxxxxxxxx" \
|
||||
-H "Authorization: api_team_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_xxxxxxxxxxxxxxxx"
|
||||
-H "Authorization: api_team_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 — 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_LOCAL_FILE_PATH` | Path to signing certificate inside container | `/opt/documenso/cert.p12` |
|
||||
| `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,7 +136,6 @@ 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" \
|
||||
@@ -155,12 +154,6 @@ 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">
|
||||
@@ -185,7 +178,6 @@ 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
|
||||
@@ -211,12 +203,6 @@ 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 24 or later
|
||||
- npm 11.17 or later
|
||||
- Node.js 22 or later
|
||||
- npm 11 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 | 24+ |
|
||||
| npm | 11.17+ |
|
||||
| Node.js | 22+ |
|
||||
| npm | 11+ |
|
||||
|
||||
---
|
||||
|
||||
@@ -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 < 24 | Modern JavaScript features required |
|
||||
| Node.js < 22 | 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** | 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. |
|
||||
| **Delegate Document Ownership** | Allow team API tokens to delegate document 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.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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"]
|
||||
@@ -0,0 +1,26 @@
|
||||
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,4 +1,3 @@
|
||||
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';
|
||||
@@ -72,7 +71,6 @@ 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);
|
||||
@@ -202,12 +200,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -52,7 +51,6 @@ export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }:
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t, i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -97,13 +95,6 @@ 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({
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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 {
|
||||
@@ -20,9 +17,7 @@ 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';
|
||||
@@ -34,8 +29,6 @@ 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 }>;
|
||||
@@ -49,9 +42,6 @@ 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: {
|
||||
@@ -61,20 +51,6 @@ 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`]);
|
||||
|
||||
@@ -95,44 +71,36 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TBulkSendFormSchema) => {
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const csv = await values.file.text();
|
||||
|
||||
const result = await uploadBulkSend({
|
||||
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);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="shrink-0" size="sm">
|
||||
@@ -206,10 +174,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
setValidationError(null);
|
||||
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
@@ -230,11 +195,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
type="button"
|
||||
variant="link"
|
||||
className="p-0 text-destructive text-xs hover:text-destructive"
|
||||
onClick={() => {
|
||||
setValidationError(null);
|
||||
|
||||
form.resetField('file');
|
||||
}}
|
||||
onClick={() => onChange(null)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -257,67 +218,6 @@ 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"
|
||||
@@ -340,12 +240,7 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
type="button"
|
||||
>
|
||||
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -78,7 +77,6 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}: EmbedDirectTemplateClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -266,13 +264,6 @@ 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),
|
||||
@@ -317,14 +308,6 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'embed_init',
|
||||
recipientId: recipient.id,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -76,7 +75,6 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
}: EmbedSignDocumentV1ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -156,14 +154,6 @@ 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(
|
||||
{
|
||||
@@ -246,15 +236,6 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'embed_init',
|
||||
recipientId: recipient.id,
|
||||
documentId,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
setHasFinishedInit(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -26,7 +25,6 @@ export const EmbedSignDocumentV2ClientPage = ({
|
||||
allowWhitelabelling = false,
|
||||
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
|
||||
useRequiredEnvelopeSigningContext();
|
||||
@@ -172,14 +170,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -58,7 +57,6 @@ export const MultiSignDocumentSigningView = ({
|
||||
}: MultiSignDocumentSigningViewProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -103,13 +101,6 @@ 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.`),
|
||||
@@ -129,13 +120,6 @@ export const MultiSignDocumentSigningView = ({
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'remove_field',
|
||||
recipientId,
|
||||
documentId: document?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,13 +140,6 @@ export const MultiSignDocumentSigningView = ({
|
||||
recipientId,
|
||||
});
|
||||
} catch (err) {
|
||||
analytics.captureException(err, {
|
||||
source: 'embed',
|
||||
location: 'complete_document',
|
||||
recipientId,
|
||||
documentId: document?.id,
|
||||
});
|
||||
|
||||
onDocumentError?.();
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -42,7 +41,6 @@ export const DirectTemplatePageView = ({
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { email, fullName, setEmail } = useRequiredDocumentSigningContext();
|
||||
const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext();
|
||||
@@ -126,13 +124,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -40,7 +39,6 @@ export const DocumentSigningCheckboxField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -128,13 +126,6 @@ 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
|
||||
@@ -166,13 +157,6 @@ 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.`),
|
||||
@@ -232,13 +216,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -89,7 +88,6 @@ export const DocumentSigningCompleteDialog = ({
|
||||
position,
|
||||
disableNameInput = false,
|
||||
}: DocumentSigningCompleteDialogProps) => {
|
||||
const analytics = useAnalytics();
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -181,11 +179,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -40,7 +39,6 @@ export const DocumentSigningDateField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -87,13 +85,6 @@ 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
|
||||
@@ -122,13 +113,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -36,7 +35,6 @@ export const DocumentSigningDropdownField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -88,13 +86,6 @@ 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
|
||||
@@ -129,13 +120,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -34,7 +33,6 @@ export const DocumentSigningEmailField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { email: providedEmail } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -80,13 +78,6 @@ 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
|
||||
@@ -115,13 +106,6 @@ 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,4 +1,3 @@
|
||||
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,7 +38,6 @@ export const DocumentSigningInitialsField = ({
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName } = useRequiredDocumentSigningContext();
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
@@ -86,13 +84,6 @@ 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
|
||||
@@ -121,13 +112,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -40,7 +39,6 @@ export const DocumentSigningNameField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext();
|
||||
|
||||
@@ -118,13 +116,6 @@ 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
|
||||
@@ -153,13 +144,6 @@ 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,5 +1,4 @@
|
||||
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';
|
||||
@@ -48,7 +47,6 @@ export const DocumentSigningNumberField = ({ field, onSignField, onUnsignField }
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -144,13 +142,6 @@ 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
|
||||
@@ -202,13 +193,6 @@ 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,3 +1,4 @@
|
||||
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';
|
||||
@@ -82,6 +83,8 @@ 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);
|
||||
|
||||
@@ -115,6 +118,12 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -32,7 +31,6 @@ export const DocumentSigningRadioField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -93,13 +91,6 @@ 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
|
||||
@@ -129,13 +120,6 @@ 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,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -42,7 +41,6 @@ export function DocumentSigningRejectDialog({
|
||||
}: DocumentSigningRejectDialogProps) {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -78,12 +76,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -48,7 +47,6 @@ export const DocumentSigningSignatureField = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -159,13 +157,6 @@ 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.`),
|
||||
@@ -192,13 +183,6 @@ 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,5 +1,4 @@
|
||||
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';
|
||||
@@ -51,7 +50,6 @@ export const DocumentSigningTextField = ({ field, onSignField, onUnsignField }:
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
|
||||
|
||||
@@ -172,13 +170,6 @@ 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
|
||||
@@ -209,13 +200,6 @@ 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,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 { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
@@ -37,6 +38,7 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const userTimezone =
|
||||
@@ -101,6 +103,12 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -38,12 +37,8 @@ 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();
|
||||
|
||||
@@ -281,13 +276,6 @@ 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);
|
||||
}
|
||||
};
|
||||
@@ -362,9 +350,6 @@ 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,5 +1,4 @@
|
||||
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';
|
||||
@@ -46,7 +45,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
const { t, i18n } = useLingui();
|
||||
const { maximumEnvelopeItemCount, remaining } = useLimits();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
@@ -215,12 +213,6 @@ 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) =>
|
||||
@@ -298,12 +290,6 @@ 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,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import {
|
||||
type PageRenderData,
|
||||
@@ -19,7 +18,6 @@ type GenericLocalField = TEnvelope['fields'][number] & {
|
||||
|
||||
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
|
||||
const { i18n } = useLingui();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
envelopeStatus,
|
||||
@@ -116,13 +114,6 @@ 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,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import {
|
||||
type PageRenderData,
|
||||
@@ -54,7 +53,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const {
|
||||
envelopeData,
|
||||
@@ -428,14 +426,6 @@ 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);
|
||||
}
|
||||
};
|
||||
@@ -517,14 +507,6 @@ 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.`,
|
||||
|
||||
+6
-14
@@ -118,6 +118,12 @@ 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) {
|
||||
@@ -142,13 +148,6 @@ 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?.();
|
||||
}
|
||||
|
||||
@@ -222,13 +221,6 @@ 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,6 +93,14 @@ 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' : '';
|
||||
@@ -101,11 +109,6 @@ 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,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 { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
@@ -35,7 +34,6 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
const { t, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
@@ -114,11 +112,6 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -69,7 +68,6 @@ export default function PDFViewer({
|
||||
}: PDFViewerProps) {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -152,11 +150,6 @@ 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.`,
|
||||
@@ -373,8 +366,6 @@ 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('');
|
||||
@@ -466,12 +457,6 @@ 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,14 +39,13 @@ 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 and
|
||||
* for the "View all results" links on capped groups. Teams, recipients 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,23 +18,6 @@ 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();
|
||||
|
||||
@@ -42,34 +25,7 @@ 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,11 +7,10 @@ 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 { EntryContext, RouterContextProvider } from 'react-router';
|
||||
import type { AppLoadContext, EntryContext } 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;
|
||||
|
||||
@@ -20,7 +19,7 @@ export default async function handleRequest(
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
loadContext: RouterContextProvider,
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
|
||||
|
||||
@@ -34,7 +33,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.get(nonceContext) || undefined;
|
||||
const nonce = loadContext.nonce || undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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();
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
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());
|
||||
};
|
||||
+2
-15
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -10,7 +9,6 @@ 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,
|
||||
@@ -23,16 +21,13 @@ 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, nonceContext } from './utils/nonce';
|
||||
|
||||
export const middleware = [nonceMiddleware];
|
||||
import { nonce } from './utils/nonce';
|
||||
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
@@ -77,7 +72,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.get(nonceContext),
|
||||
nonce: context.nonce,
|
||||
session: session.isAuthenticated
|
||||
? {
|
||||
user: session.user,
|
||||
@@ -205,19 +200,11 @@ 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 { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getSession } 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,18 +20,16 @@ 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 getOptionalSession(request);
|
||||
const { user } = await getSession(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 { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
@@ -30,12 +30,6 @@ 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,
|
||||
@@ -137,7 +131,7 @@ export default function AdminDocumentsPage() {
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search by document title, recipient:123, team:123 or user:123`)}
|
||||
placeholder={_(msg`Search by document title, team:123, user:123 or recipient:email@example.com`)}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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,7 +7,6 @@ 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';
|
||||
|
||||
@@ -68,18 +66,8 @@ 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,20 +68,6 @@ 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 }) => (
|
||||
@@ -160,9 +146,6 @@ 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,5 +1,4 @@
|
||||
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';
|
||||
@@ -9,7 +8,6 @@ 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';
|
||||
@@ -86,18 +84,8 @@ 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,4 +1,3 @@
|
||||
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';
|
||||
@@ -6,7 +5,6 @@ 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';
|
||||
@@ -50,14 +48,8 @@ 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 = async () => {
|
||||
export const loader = () => {
|
||||
try {
|
||||
const certStatus = await getCertificateStatus();
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
return Response.json({
|
||||
isAvailable: certStatus.isAvailable,
|
||||
|
||||
@@ -22,7 +22,7 @@ export const loader = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const certStatus = await getCertificateStatus();
|
||||
const certStatus = getCertificateStatus();
|
||||
|
||||
if (certStatus.isAvailable) {
|
||||
checks.certificate = { status: 'ok' };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import {
|
||||
IS_GOOGLE_SSO_ENABLED,
|
||||
IS_MICROSOFT_SSO_ENABLED,
|
||||
@@ -6,7 +5,6 @@ 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';
|
||||
@@ -49,30 +47,10 @@ 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,13 +2,11 @@ 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';
|
||||
@@ -95,30 +93,6 @@ 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,
|
||||
@@ -220,30 +194,6 @@ 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,7 +2,6 @@ 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';
|
||||
@@ -14,7 +13,6 @@ 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';
|
||||
@@ -141,30 +139,6 @@ 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,
|
||||
@@ -298,30 +272,6 @@ 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,11 +1,8 @@
|
||||
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';
|
||||
@@ -38,40 +35,6 @@ 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,11 +1,8 @@
|
||||
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';
|
||||
@@ -45,15 +42,6 @@ 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,
|
||||
@@ -67,29 +55,6 @@ 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,6 +1,5 @@
|
||||
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';
|
||||
@@ -13,7 +12,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 { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { isRouteErrorResponse, Outlet, useLoaderData } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -167,17 +166,8 @@ 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,6 +1,5 @@
|
||||
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';
|
||||
@@ -11,7 +10,6 @@ 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';
|
||||
@@ -78,30 +76,6 @@ 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,6 +1,5 @@
|
||||
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';
|
||||
@@ -95,18 +94,6 @@ 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,10 +1,4 @@
|
||||
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>('');
|
||||
import { useRouteLoaderData } from 'react-router';
|
||||
|
||||
/**
|
||||
* 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.18.0"
|
||||
"version": "2.17.0"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,4 @@ 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,11 +24,12 @@ export const appContext = async (c: Context, next: Next) => {
|
||||
|
||||
// These are non page paths like API.
|
||||
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
|
||||
return await next();
|
||||
return next();
|
||||
}
|
||||
|
||||
// Add context to any pages you want here.
|
||||
return await next();
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
const setAppContext = (c: Context, context: AppContext) => {
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { getContext } from 'hono/context-storage';
|
||||
import { RouterContextProvider } from 'react-router';
|
||||
import type { AppLoadContext } from 'react-router';
|
||||
|
||||
import type { HonoEnv } from './router';
|
||||
import { CSP_NONCE_KEY } from './security-headers';
|
||||
|
||||
/**
|
||||
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
|
||||
* `hono/context-storage` (enabled in `server/router.ts`).
|
||||
* Augment React Router's `AppLoadContext` so loaders, actions, and
|
||||
* `entry.server` can access fields by name without casts.
|
||||
*/
|
||||
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
|
||||
* 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`.
|
||||
*/
|
||||
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
|
||||
export const getLoadContext = (): AppLoadContext => {
|
||||
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
|
||||
|
||||
return { nonce };
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -54,7 +53,7 @@ export default defineConfig({
|
||||
entry: 'server/router.ts',
|
||||
getLoadContext: async () => {
|
||||
const { getLoadContext } = await import('./server/load-context');
|
||||
return getLoadContext() as unknown as AppLoadContext;
|
||||
return getLoadContext();
|
||||
},
|
||||
exclude: [
|
||||
// Spread the defaults but replace the /.css$/ rule so that Bull
|
||||
|
||||
+12
-28
@@ -1,7 +1,7 @@
|
||||
###########################
|
||||
# BASE CONTAINER #
|
||||
###########################
|
||||
FROM node:24-alpine3.23 AS base
|
||||
FROM node:22-alpine3.22 AS base
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
RUN apk add --no-cache font-freefont
|
||||
@@ -19,10 +19,7 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
# 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}"
|
||||
RUN npm install -g "turbo@^2.10.0"
|
||||
|
||||
# Outputs to the /out folder
|
||||
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
|
||||
@@ -42,9 +39,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"
|
||||
@@ -79,7 +76,6 @@ 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
|
||||
|
||||
@@ -88,17 +84,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
|
||||
|
||||
ENV NODE_OPTIONS="--max-old-space-size=8192"
|
||||
RUN npm install -g "turbo@^2.10.0"
|
||||
|
||||
RUN npx turbo run build --filter=@documenso/remix...
|
||||
RUN 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=""
|
||||
@@ -118,19 +114,8 @@ 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 --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
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nodejs.org/docs/advanced-features/output-file-tracing
|
||||
@@ -141,9 +126,8 @@ 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, this time only targeting the client generator
|
||||
RUN npx prisma generate --schema ./packages/prisma/schema.prisma --generator client \
|
||||
&& npm cache clean --force
|
||||
# Generate the prisma client again
|
||||
RUN npx prisma generate --schema ./packages/prisma/schema.prisma
|
||||
|
||||
|
||||
# Get the start script from docker/
|
||||
|
||||
Generated
+14
-35
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -15,7 +15,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "5.0.48",
|
||||
"@documenso/prisma": "*",
|
||||
"@libpdf/core": "^0.4.2",
|
||||
"@libpdf/core": "^0.4.1",
|
||||
"@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": ">=24.0.0",
|
||||
"npm": ">=11.17.0"
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=11.11.0"
|
||||
}
|
||||
},
|
||||
"apps/docs": {
|
||||
@@ -416,7 +416,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -3153,7 +3153,6 @@
|
||||
"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",
|
||||
@@ -3165,7 +3164,6 @@
|
||||
"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",
|
||||
@@ -3176,14 +3174,12 @@
|
||||
"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": {
|
||||
@@ -4806,9 +4802,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@libpdf/core": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.2.tgz",
|
||||
"integrity": "sha512-lbkIqLDZCCxjLpiC+8/Xvaru/ME7iVVoihl9tLqbp/CDUWZNF0q3u7s2tBJB9wRW/SzUWID6YPFvBWws770hrQ==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.1.tgz",
|
||||
"integrity": "sha512-DWGxWw1na8oFPixz+b6kOgu4tMD8xJLDgyPGVUNqIswO5POHAxsqmTvUvDW0IrwHP7kFRHBV3I3T/KhTABf9rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
@@ -5727,7 +5723,6 @@
|
||||
"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",
|
||||
@@ -10300,14 +10295,12 @@
|
||||
"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"
|
||||
@@ -10356,14 +10349,12 @@
|
||||
"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",
|
||||
@@ -10384,7 +10375,6 @@
|
||||
"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",
|
||||
@@ -10415,21 +10405,18 @@
|
||||
"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",
|
||||
@@ -16287,7 +16274,6 @@
|
||||
"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",
|
||||
@@ -16639,7 +16625,6 @@
|
||||
"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": {
|
||||
@@ -19886,7 +19871,6 @@
|
||||
"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",
|
||||
@@ -22553,7 +22537,6 @@
|
||||
"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"
|
||||
@@ -26400,7 +26383,6 @@
|
||||
"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",
|
||||
@@ -26426,7 +26408,6 @@
|
||||
"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",
|
||||
@@ -27627,7 +27608,6 @@
|
||||
"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": {
|
||||
@@ -31674,7 +31654,6 @@
|
||||
"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",
|
||||
@@ -32431,17 +32410,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": "^3.25.76",
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"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",
|
||||
"zod-prisma-types": "3.3.5"
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
},
|
||||
"packages/prisma/node_modules/@esbuild/aix-ppc64": {
|
||||
|
||||
+5
-5
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.18.0",
|
||||
"version": "2.17.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.19.1",
|
||||
"packageManager": "npm@11.11.0",
|
||||
"engines": {
|
||||
"npm": ">=11.17.0",
|
||||
"node": ">=24.0.0"
|
||||
"npm": ">=11.11.0",
|
||||
"node": ">=22.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.2",
|
||||
"@libpdf/core": "^0.4.1",
|
||||
"@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, organizations…';
|
||||
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
@@ -437,98 +437,3 @@ 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('Organization Settings')).not.toBeVisible();
|
||||
await expect(page.getByText('Organisation 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 organization`)).toBeVisible();
|
||||
await expect(page.getByText(`Manage organisation`)).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Organization Name')).toHaveValue(organisation.name);
|
||||
await expect(page.getByLabel('Organisation 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: 'Organization not found' })).toBeVisible({
|
||||
await expect(page.getByRole('heading', { name: 'Organisation 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('Organization Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organization Name*')).toBeEnabled();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
|
||||
|
||||
// Should have delete permissions
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
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 canceled');
|
||||
await expectToastTextToBeVisible(page, 'Documents cancelled');
|
||||
|
||||
// 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 canceled');
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
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 canceled');
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
// 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 canceling this document').fill(reason);
|
||||
await page.getByPlaceholder('Add an optional reason for cancelling 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 canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// 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, 'Canceled');
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
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 canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
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 canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await selectDocumentStatusFilter(page, 'Canceled');
|
||||
await selectDocumentStatusFilter(page, 'Cancelled');
|
||||
|
||||
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 canceled');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
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 organization' }).click();
|
||||
await page.getByRole('button', { name: 'Create organisation' }).click();
|
||||
|
||||
await page.getByLabel('Organization Name*').fill('test');
|
||||
await page.getByLabel('Organisation Name*').fill('test');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await expect(page.getByText('Your organization has been created').first()).toBeVisible();
|
||||
await expect(page.getByText('Your organisation 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('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.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.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: 'Organization Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organization Admin' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation 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: '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();
|
||||
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();
|
||||
|
||||
// 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: 'Organization Admin' }).click();
|
||||
await page.getByRole('option', { name: 'Organization Member' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation 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: '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();
|
||||
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();
|
||||
|
||||
// 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: 'Organization Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organization Admin' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
|
||||
await page.getByRole('option', { name: 'Organisation 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 organization').first()).toBeVisible();
|
||||
await expect(page.getByText('You have successfully left this organisation').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 organization has exceeded a fair use limit';
|
||||
const BANNER_NEARING_TEXT = 'Your organization is approaching a fair use limit';
|
||||
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
|
||||
const BANNER_NEARING_TEXT = 'Your organisation 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('Organization Name*')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation 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 organization' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).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 organization' })
|
||||
.filter({ hasText: 'Inherit from organisation' })
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Override organization settings' }).click();
|
||||
await page.getByRole('option', { name: 'Override organisation 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 organization settings' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organization' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).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('Organization Settings');
|
||||
await expect(page.getByTestId('settings-scope-breadcrumb-chip')).toContainText('Organisation 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 organization/i }).click();
|
||||
await page.getByRole('option', { name: /inherit from organisation/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 organization/i);
|
||||
await expect(emptyState).toContainText(/permission to manage this organisation/i);
|
||||
|
||||
// Team and account pages remain navigable.
|
||||
await expect(sidebar.getByTestId('unified-settings-nav-team-general')).toBeVisible();
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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,21 +31,18 @@ export function useAnalytics() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture an exception event.
|
||||
* Capture an analytic event.
|
||||
*
|
||||
* @param error The error to capture.
|
||||
* @param properties Properties to attach to the event, such as `source`, `location`,
|
||||
* `recipientId` or `envelopeId`. Never attach recipient tokens.
|
||||
* @param properties Properties to attach to the event.
|
||||
*/
|
||||
const captureException = (error: unknown, properties?: Record<string, unknown>) => {
|
||||
const captureException = (error: Error, properties?: Record<string, unknown>) => {
|
||||
if (!isPostHogEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorToCapture = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
void getPosthog().then(({ default: posthog }) => {
|
||||
posthog.captureException(errorToCapture, properties);
|
||||
posthog.captureException(error, properties);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ 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';
|
||||
@@ -105,7 +104,6 @@ export const EnvelopeEditorProvider = ({
|
||||
}: EnvelopeEditorProviderProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -243,12 +241,6 @@ export const EnvelopeEditorProvider = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'autosave_recipients',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@@ -312,12 +304,6 @@ export const EnvelopeEditorProvider = ({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
analytics.captureException(err, {
|
||||
source: isEmbedded ? 'embed' : 'editor',
|
||||
location: 'autosave_fields',
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
@@ -373,12 +359,6 @@ 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,8 +93,6 @@ 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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* The maximum value of a Postgres `INT4` (32-bit signed integer) column, which
|
||||
* is what Prisma `Int` ID columns are stored as. Filtering an `Int` column by
|
||||
* a larger value makes Prisma throw a conversion error at runtime.
|
||||
*/
|
||||
export const MAX_POSTGRES_INT = 2147483647;
|
||||
@@ -2,10 +2,12 @@ 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 { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
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';
|
||||
@@ -15,6 +17,14 @@ 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;
|
||||
|
||||
@@ -31,20 +41,24 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const { recipients } = template;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = parse<any>(csvContent, { columns: true, skip_empty_lines: true });
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
if (!csvValidationResult.success) {
|
||||
throw new Error(`Bulk send CSV failed validation: ${JSON.stringify(csvValidationResult.error)}`);
|
||||
if (rows.length > 100) {
|
||||
throw new Error('Maximum 100 rows allowed per upload');
|
||||
}
|
||||
|
||||
const rows = csvValidationResult.data;
|
||||
const { recipients } = template;
|
||||
|
||||
// Validate CSV structure
|
||||
const csvHeaders = Object.keys(rows[0]);
|
||||
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
|
||||
|
||||
for (const header of requiredHeaders) {
|
||||
if (!csvHeaders.includes(header)) {
|
||||
throw new Error(`Missing required column: ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -65,6 +79,22 @@ 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: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, type Prisma } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MAX_POSTGRES_INT } from '../../constants/database';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
|
||||
export interface AdminFindDocumentsOptions {
|
||||
@@ -10,12 +11,6 @@ export interface AdminFindDocumentsOptions {
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
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 = {
|
||||
@@ -67,38 +62,38 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
if (query?.startsWith('recipient:')) {
|
||||
const recipientQuery = query.slice('recipient:'.length).trim();
|
||||
|
||||
if (recipientQuery.length === 0) {
|
||||
if (!recipientQuery) {
|
||||
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.
|
||||
// Match admin-global-search semantics: only bare digit strings are ID
|
||||
// lookups, so numeric-looking email fragments (1e3, 0x10, +40) stay text.
|
||||
const isRecipientIdLookup = /^\d+$/.test(recipientQuery);
|
||||
|
||||
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' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
termFilters =
|
||||
isRecipientIdLookup && parsedRecipientId.success
|
||||
? {
|
||||
recipients: {
|
||||
some: {
|
||||
id: parsedRecipientId.data,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
recipients: {
|
||||
some: {
|
||||
email: {
|
||||
contains: recipientQuery,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (query?.startsWith('envelope_')) {
|
||||
if (query && query?.startsWith('envelope_')) {
|
||||
termFilters = {
|
||||
id: {
|
||||
equals: query,
|
||||
@@ -106,7 +101,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
};
|
||||
}
|
||||
|
||||
if (query?.startsWith('document_')) {
|
||||
if (query && query?.startsWith('document_')) {
|
||||
termFilters = {
|
||||
secondaryId: {
|
||||
equals: query,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
|
||||
import { MAX_POSTGRES_INT } from '../../constants/database';
|
||||
|
||||
const MAX_POSTGRES_INT = 2147483647;
|
||||
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
|
||||
|
||||
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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,36 +1,26 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { createLocalSigner } from '@documenso/signing/transports/local';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
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') {
|
||||
export const getCertificateStatus = () => {
|
||||
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
// Anything else (typo, leftover `http`) would throw at seal time.
|
||||
if (transport !== 'local') {
|
||||
return { isAvailable: false };
|
||||
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
|
||||
return { isAvailable: true };
|
||||
}
|
||||
|
||||
const defaultPath = env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
|
||||
|
||||
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
|
||||
|
||||
try {
|
||||
const signer = await createLocalSigner({ buildChain: false });
|
||||
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
|
||||
|
||||
const certificate = new X509Certificate(Buffer.from(signer.certificate));
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const isWithinValidityPeriod = new Date(certificate.validFrom) <= now && now <= new Date(certificate.validTo);
|
||||
|
||||
return { isAvailable: isWithinValidityPeriod };
|
||||
return { isAvailable: stats.size > 0 };
|
||||
} catch {
|
||||
return { isAvailable: false };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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;
|
||||
|
||||
@@ -99,29 +96,6 @@ 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,7 +22,6 @@ export const getApiTokens = async ({ userId, teamId }: GetApiTokensOptions) => {
|
||||
name: true,
|
||||
createdAt: true,
|
||||
expires: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
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' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
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,6 +35,33 @@ 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,20 +81,13 @@ describe('isPrivateUrl', () => {
|
||||
expect(isPrivateUrl('http://[fd12::1]')).toBe(true);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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);
|
||||
expect(isPrivateUrl('http://[::ffff:8.8.8.8]')).toBe(false);
|
||||
expect(isPrivateUrl('http://[::ffff:1.1.1.1]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -69,25 +69,11 @@ export const isPrivateUrl = (url: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6, dotted form (e.g. ::ffff:127.0.0.1)
|
||||
const v4MappedDotted = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
|
||||
const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
|
||||
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}`);
|
||||
if (v4Mapped) {
|
||||
return isPrivateUrl(`http://${v4Mapped[1]}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -312,12 +312,6 @@ 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"
|
||||
@@ -440,10 +434,6 @@ 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.}}"
|
||||
@@ -524,11 +514,7 @@ 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, plural, one {# item} other {# items}}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "{totalVisibleCount, plural, one {# result} other {# results}}"
|
||||
msgid "{totalVisibleCount} items"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/utils/document-audit-logs.ts
|
||||
@@ -1235,10 +1221,6 @@ 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"
|
||||
@@ -1836,6 +1818,10 @@ 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."
|
||||
@@ -2469,10 +2455,6 @@ 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"
|
||||
@@ -3006,14 +2988,6 @@ 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"
|
||||
@@ -3200,11 +3174,6 @@ 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"
|
||||
@@ -3213,10 +3182,6 @@ 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"
|
||||
@@ -3269,10 +3234,6 @@ 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."
|
||||
@@ -4374,10 +4335,6 @@ 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 ""
|
||||
@@ -4388,7 +4345,6 @@ 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"
|
||||
|
||||
@@ -4563,18 +4519,10 @@ 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"
|
||||
@@ -4602,7 +4550,6 @@ 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"
|
||||
|
||||
@@ -5031,10 +4978,6 @@ 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
|
||||
@@ -5553,8 +5496,6 @@ 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"
|
||||
|
||||
@@ -5621,6 +5562,7 @@ 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
|
||||
@@ -5631,6 +5573,7 @@ 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
|
||||
@@ -5800,6 +5743,10 @@ 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."
|
||||
@@ -6200,10 +6147,6 @@ 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"
|
||||
@@ -6436,10 +6379,6 @@ 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"
|
||||
@@ -6850,10 +6789,6 @@ 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"
|
||||
@@ -6972,10 +6907,6 @@ 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"
|
||||
@@ -7174,7 +7105,6 @@ 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."
|
||||
@@ -7601,10 +7531,6 @@ 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 ""
|
||||
@@ -7632,7 +7558,6 @@ 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"
|
||||
|
||||
@@ -8019,10 +7944,6 @@ 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"
|
||||
@@ -8206,7 +8127,6 @@ 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"
|
||||
@@ -8753,11 +8673,6 @@ 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."
|
||||
@@ -8944,10 +8859,6 @@ 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"
|
||||
@@ -9276,10 +9187,6 @@ 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"
|
||||
@@ -9648,10 +9555,6 @@ 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"
|
||||
@@ -9813,8 +9716,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, recipient:123, team:123 or user:123"
|
||||
msgstr ""
|
||||
msgid "Search by document title, team:123 or user:123"
|
||||
msgstr "Nach Dokumenttitel, team:123 oder user:123 suchen"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx
|
||||
msgid "Search by domain or organisation name"
|
||||
@@ -10168,10 +10071,6 @@ 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"
|
||||
@@ -10180,10 +10079,6 @@ 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"
|
||||
@@ -10230,14 +10125,6 @@ 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..."
|
||||
@@ -10258,10 +10145,6 @@ 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"
|
||||
@@ -10367,7 +10250,6 @@ 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
|
||||
@@ -10539,10 +10421,6 @@ 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"
|
||||
@@ -10607,10 +10485,6 @@ 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"
|
||||
@@ -10663,10 +10537,6 @@ 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"
|
||||
@@ -10686,10 +10556,6 @@ 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 ""
|
||||
@@ -10711,6 +10577,7 @@ 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
|
||||
@@ -10720,6 +10587,8 @@ 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
|
||||
@@ -10742,7 +10611,6 @@ 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"
|
||||
@@ -11269,7 +11137,6 @@ 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
|
||||
@@ -12025,10 +11892,6 @@ 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."
|
||||
@@ -12057,10 +11920,6 @@ 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."
|
||||
@@ -12079,26 +11938,14 @@ 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."
|
||||
@@ -12111,10 +11958,6 @@ 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."
|
||||
@@ -12830,18 +12673,6 @@ 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"
|
||||
@@ -13015,10 +12846,6 @@ 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"
|
||||
@@ -13505,8 +13332,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 ""
|
||||
msgid "Waiting for others to complete signing."
|
||||
msgstr "Warten auf andere, um die Unterzeichnung abzuschließen."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||
msgid "Waiting for others to sign"
|
||||
@@ -13835,7 +13662,9 @@ 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/utils/toast-error-messages.ts
|
||||
#: 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
|
||||
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."
|
||||
@@ -14001,11 +13830,6 @@ 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}"
|
||||
@@ -14083,10 +13907,6 @@ 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"
|
||||
@@ -14337,6 +14157,10 @@ 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."
|
||||
@@ -15100,10 +14924,6 @@ 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"
|
||||
@@ -15237,10 +15057,6 @@ 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"
|
||||
|
||||
+287
-471
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user